Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Constructive Functions

Serverless function workloads (send-email, send-verification-link) with a job queue system deployed via Kubernetes.
Serverless function workloads (send-email, send-verification-link, send-sms) with a job queue system deployed via Kubernetes.

## Project Structure

Expand Down Expand Up @@ -93,6 +93,9 @@ Edit `functions/<name>/handler.ts` → Skaffold syncs the file into the containe
| Job Service | 8080 |
| send-email | 8081 |
| send-verification-link | 8082 |
| send-sms | 8086 |
| DevSms API | 4000 |
| DevSms UI | 5153 |

## Debugging K8s Pods

Expand All @@ -113,6 +116,7 @@ kubectl logs -n constructive-functions -l app=knative-job-service -f
# Function logs
kubectl logs -n constructive-functions -l app=send-email -f
kubectl logs -n constructive-functions -l app=send-verification-link -f
kubectl logs -n constructive-functions -l app=send-sms -f

# Constructive server logs
kubectl logs -n constructive-functions -l app=constructive-server -f
Expand All @@ -135,6 +139,9 @@ kubectl port-forward -n constructive-functions svc/postgres 5432:5432
kubectl port-forward -n constructive-functions svc/knative-job-service 8080:8080
kubectl port-forward -n constructive-functions svc/send-email 8081:80
kubectl port-forward -n constructive-functions svc/send-verification-link 8082:80
kubectl port-forward -n constructive-functions svc/send-sms 8086:80
kubectl port-forward -n constructive-functions svc/devsms 4000:4000
kubectl port-forward -n constructive-functions svc/devsms 5153:5153
kubectl port-forward -n constructive-functions svc/constructive-server 3002:3000
```

Expand Down
32 changes: 28 additions & 4 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Development Guide

Local development setup for running functions against real infrastructure (Postgres, GraphQL, Mailpit).
Local development setup for running functions against real infrastructure (Postgres, GraphQL, Mailpit, DevSms).

## Prerequisites

Expand Down Expand Up @@ -32,7 +32,7 @@ pnpm install
# 3. Build everything (packages, job service, generated functions)
pnpm build

# 4. Start infrastructure (Postgres, DB migrations, GraphQL server, Mailpit)
# 4. Start infrastructure (Postgres, DB migrations, GraphQL server, Mailpit, DevSms)
make dev

# 5. Wait for db-setup to finish (watch logs)
Expand Down Expand Up @@ -60,6 +60,7 @@ After this you should have built artifacts in:
|---------|--------|
| `generated/send-verification-link/dist/` | Send-verification-link function server |
| `generated/send-email/dist/` | Send-email function server |
| `generated/send-sms/dist/` | Send SMS verification code function server |
| `generated/example/dist/` | knative-job-example function server |
| `generated/python-example/dist/` | Python example function server |
| `job/service/dist/` | Knative job service (worker + scheduler) |
Expand All @@ -80,6 +81,7 @@ This runs `docker compose up -d` which starts:
| **db-setup** | One-shot: creates DB, bootstraps roles, deploys pgpm packages | (exits on completion) |
| **graphql-server** | Constructive admin GraphQL API (header-based routing) | 3002 |
| **mailpit** | SMTP capture server with web UI | 1025 (SMTP), 8025 (UI) |
| **devsms** | Local SMS inbox/API for development verification codes | 4000 (API), 5153 (UI) |

The `db-setup` container must finish before `graphql-server` starts (enforced by `service_completed_successfully`). Watch progress:

Expand All @@ -100,6 +102,7 @@ You should see:
- `db-setup` — exited (0)
- `graphql-server` — running
- `mailpit` — running
- `devsms` — running

### 3. Start Functions Locally

Expand All @@ -114,6 +117,7 @@ This runs `scripts/dev.ts` which spawns local Node processes with env vars point
| **job-service** | 8080 | `job/service/dist/run.js` |
| **send-email** | 8081 | `generated/send-email/dist/index.js` |
| **send-verification-link** | 8082 | `generated/send-verification-link/dist/index.js` |
| **send-sms** | 8086 | `generated/send-sms/dist/index.js` |
| **knative-job-example** | 8083 | `generated/example/dist/index.js` |
| **python-example** | 8084 | `generated/python-example/...` (python entrypoint) |

Expand All @@ -136,6 +140,21 @@ curl -X POST http://localhost:8082 \

Check captured emails at http://localhost:8025 (Mailpit UI).

Send a request to `send-sms` and check captured SMS at http://localhost:5153 (DevSms UI):

```bash
curl -X POST http://localhost:8086 \
-H 'Content-Type: application/json' \
-H 'X-Database-Id: constructive' \
-d '{"sms_type":"sms_otp_code","phone":"+14155550123","code":"012345"}'
```

Query DevSms messages through its API:

```bash
curl "http://localhost:4000/api/sms?limit=10"
```

Query the GraphQL API directly:

```bash
Expand Down Expand Up @@ -175,9 +194,12 @@ make dev-down # Stop Docker infrastructure
| GraphQL API | 3002 |
| Mailpit SMTP | 1025 |
| Mailpit UI | 8025 |
| DevSms API | 4000 |
| DevSms UI | 5153 |
| Job Service | 8080 |
| send-email | 8081 |
| send-verification-link | 8082 |
| send-sms | 8086 |
| knative-job-example | 8083 |
| python-example | 8084 |

Expand All @@ -187,11 +209,13 @@ make dev-down # Stop Docker infrastructure
Docker Compose (infrastructure):
postgres -> db-setup (migrations) -> graphql-server
mailpit
devsms

Local Node processes (functions):
job/service/dist/run.js (port 8080)
generated/send-email/dist/index.js (port 8081)
generated/send-verification-link/dist/index.js (port 8082)
generated/send-email/dist/index.js (port 8081)
generated/send-verification-link/dist/index.js (port 8082)
generated/send-sms/dist/index.js (port 8086)
```

Infrastructure runs in Docker. Functions run as local Node processes from `generated/` — no Docker rebuild needed when function code changes. Edit `functions/*/handler.ts`, rebuild (`pnpm build`), restart `make dev-fn`.
Expand Down
6 changes: 6 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,5 +93,11 @@ services:
- "1025:1025" # SMTP
- "8025:8025" # Web UI

devsms:
image: ghcr.io/mrmeaow/devsms:latest
ports:
- "4000:4000" # API
- "5153:5153" # Web UI

volumes:
pgdata:
19 changes: 19 additions & 0 deletions functions/send-sms/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# send-sms

Handles `sms:send_verification_code` jobs by validating the job payload, normalizing the recipient phone number to E.164, rendering the verification SMS body, and sending it through the configured SMS provider.

Current provider support is intentionally local-only:

- `SMS_PROVIDER=devsms`
- `DEVSMS_BASE_URL=http://localhost:4000` for Docker Compose local development
- DevSms endpoint: `POST /api/sms/send/twilio`

All SMS configuration is loaded through `@constructive-io/graphql-env` via `getEnvOptions({}, process.cwd(), context.env)`. The handler must not read `SMS_*` or `DEVSMS_*` values directly.

## Retry and idempotency

The job worker may retry a job after a timeout or provider error. The `SmsSendRequest.metadata` includes `jobId` and `databaseId` for future idempotency support, but DevSms does not currently expose an idempotency key. A retried job can therefore create duplicate local SMS messages. The handler intentionally does not implement its own retry loop; timeout and transient failures are left to the existing job retry mechanism.

## Logging

Logs include job metadata, SMS type, provider, provider message ID, status, and a masked phone number only. They must not include the OTP code, full SMS body, full phone number, or provider secrets.
134 changes: 134 additions & 0 deletions functions/send-sms/__tests__/devsms.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { DevSmsProvider } from '../providers/devsms';
import type { SmsSendRequest } from '../providers/types';

const request: SmsSendRequest = {
to: '+14155550123',
body: 'Your sign-in code is 123456. Do not share this code.',
senderId: 'TestSender',
metadata: {
jobId: 'job-1',
databaseId: 'db-1',
purpose: 'sign_in_otp'
}
};

const jsonResponse = (body: unknown, status = 201): Response =>
new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' }
});

describe('DevSmsProvider', () => {
it('sends the correct URL, method, headers, and request body', async () => {
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({
id: 'row_1',
provider_message_id: 'SM123',
status: 'queued'
}));
const provider = new DevSmsProvider({
baseUrl: 'http://devsms:4000',
requestTimeoutMs: 5000,
fetchImpl: fetchImpl as unknown as typeof fetch
});

await provider.send(request);

expect(fetchImpl).toHaveBeenCalledWith(
'http://devsms:4000/api/sms/send/twilio',
expect.objectContaining({
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
From: 'TestSender',
To: '+14155550123',
Body: 'Your sign-in code is 123456. Do not share this code.'
})
})
);
});

it('maps provider responses to SmsSendResult', async () => {
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({
id: 'row_1',
provider_message_id: 'SM123',
status: 'sent'
}));
const provider = new DevSmsProvider({
baseUrl: 'http://devsms:4000/',
requestTimeoutMs: 5000,
fetchImpl: fetchImpl as unknown as typeof fetch
});

await expect(provider.send(request)).resolves.toEqual({
provider: 'devsms',
messageId: 'SM123',
status: 'sent'
});
});

it('throws for non-2xx responses without exposing the response body', async () => {
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({
error: 'OTP 123456 failed for +14155550123'
}, 400));
const provider = new DevSmsProvider({
baseUrl: 'http://devsms:4000',
requestTimeoutMs: 5000,
fetchImpl: fetchImpl as unknown as typeof fetch
});

const error = await provider.send(request).then(
() => undefined,
(cause: unknown) => cause as Error
);

expect(error).toBeInstanceOf(Error);
expect(error?.message).toBe('DevSmsProvider request failed with 400');
expect(error?.message).not.toContain('123456');
expect(error?.message).not.toContain('+14155550123');
});

it('throws on timeout', async () => {
jest.useFakeTimers();
const fetchImpl = jest.fn((_url: string, init: RequestInit) =>
new Promise((_resolve, reject) => {
init.signal?.addEventListener('abort', () => {
const error = new Error('aborted');
error.name = 'AbortError';
reject(error);
});
})
);
const provider = new DevSmsProvider({
baseUrl: 'http://devsms:4000',
requestTimeoutMs: 10,
fetchImpl: fetchImpl as unknown as typeof fetch
});

const promise = provider.send(request);
jest.advanceTimersByTime(10);
await expect(promise).rejects.toThrow('DevSmsProvider timed out after 10ms');
jest.useRealTimers();
});

it('throws for invalid JSON responses', async () => {
const fetchImpl = jest.fn().mockResolvedValue(new Response('not-json', { status: 201 }));
const provider = new DevSmsProvider({
baseUrl: 'http://devsms:4000',
requestTimeoutMs: 5000,
fetchImpl: fetchImpl as unknown as typeof fetch
});

await expect(provider.send(request)).rejects.toThrow('DevSmsProvider returned invalid JSON');
});

it('throws when the response is missing a message ID', async () => {
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({ status: 'queued' }));
const provider = new DevSmsProvider({
baseUrl: 'http://devsms:4000',
requestTimeoutMs: 5000,
fetchImpl: fetchImpl as unknown as typeof fetch
});

await expect(provider.send(request)).rejects.toThrow('DevSmsProvider response missing message ID');
});
});
Loading
Loading