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:
35 changes: 35 additions & 0 deletions functions/send-sms/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 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.

The send-sms consumer resolves defaults only after `graphql-env` has merged config files and environment overrides:

- `SMS_REQUEST_TIMEOUT_MS` has an all-environment default of `5000`.
- `SEND_SMS_DRY_RUN` defaults to `false` in development and tests.
- Production must explicitly configure `SEND_SMS_DRY_RUN=true` or `false`.
- The provider and provider-specific settings have no global defaults.

<!-- TEMP-DEVSMS-CROSS-PR: Delete this cleanup section after completing it. -->
## Temporary cross-PR cleanup

`constructive#1383` is merged, but its SMS-aware `graphql-env` and `graphql-types` builds do not yet have new npm versions. After those versions are published:

1. Upgrade `@constructive-io/graphql-env` in `handler.json` and regenerate the lockfile.
2. Replace the local `SmsOptions` compatibility types and cast in `config.ts` with the published types.
3. Replace the virtual config mock with a real package integration test and remove the handler test's `virtual: true`.

## 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.
138 changes: 138 additions & 0 deletions functions/send-sms/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
const mockGetEnvOptions = jest.fn();

// TEMP-DEVSMS-CROSS-PR: Replace this virtual mock with coverage against the real
// SMS-aware graphql-env package after the first post-constructive#1383 release.
jest.mock('@constructive-io/graphql-env', () => ({
getEnvOptions: mockGetEnvOptions
}), { virtual: true });

import { loadSmsOptions } from '../config';

describe('send-sms configuration', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it.each(['development', 'test'])(
'applies consumer defaults in %s',
(nodeEnv) => {
mockGetEnvOptions.mockReturnValue({
sms: {
provider: 'devsms',
devsms: {
baseUrl: 'http://devsms:4000'
}
}
});

expect(loadSmsOptions({ NODE_ENV: nodeEnv }, '/tmp/send-sms')).toEqual({
provider: 'devsms',
requestTimeoutMs: 5000,
dryRun: false,
devsms: {
baseUrl: 'http://devsms:4000'
}
});
}
);

it('requires an explicit dry-run choice in production', () => {
mockGetEnvOptions.mockReturnValue({
sms: {
provider: 'twilio'
}
});

expect(() =>
loadSmsOptions({ NODE_ENV: 'production' }, '/tmp/send-sms')
).toThrow('SEND_SMS_DRY_RUN');
});

it('rejects a malformed raw dry-run override before it can become false', () => {
mockGetEnvOptions.mockReturnValue({
sms: {
provider: 'twilio',
dryRun: false
}
});

expect(() =>
loadSmsOptions(
{
NODE_ENV: 'production',
SEND_SMS_DRY_RUN: 'treu'
},
'/tmp/send-sms'
)
).toThrow('SEND_SMS_DRY_RUN');
expect(mockGetEnvOptions).not.toHaveBeenCalled();
});

it.each(['not-a-number', '5s'])(
'rejects malformed raw timeout %s instead of applying the fallback',
(timeout) => {
mockGetEnvOptions.mockReturnValue({
sms: {
provider: 'devsms',
requestTimeoutMs: 5000,
dryRun: false
}
});

expect(() =>
loadSmsOptions(
{
NODE_ENV: 'development',
SMS_REQUEST_TIMEOUT_MS: timeout
},
'/tmp/send-sms'
)
).toThrow('SMS_REQUEST_TIMEOUT_MS');
expect(mockGetEnvOptions).not.toHaveBeenCalled();
}
);

it('preserves an explicit false dry-run value in production', () => {
mockGetEnvOptions.mockReturnValue({
sms: {
provider: 'twilio',
dryRun: false
}
});

expect(
loadSmsOptions({ NODE_ENV: 'production' }, '/tmp/send-sms')
).toEqual({
provider: 'twilio',
requestTimeoutMs: 5000,
dryRun: false
});
});

it('preserves the final values merged by graphql-env', () => {
const inputEnv = { NODE_ENV: 'test' };
const cwd = '/tmp/send-sms-config';
mockGetEnvOptions.mockReturnValue({
sms: {
provider: 'custom',
senderId: 'ConfiguredSender',
requestTimeoutMs: 3200,
dryRun: true,
devsms: {
baseUrl: 'http://configured-devsms:4000'
}
}
});

expect(loadSmsOptions(inputEnv, cwd)).toEqual({
provider: 'custom',
senderId: 'ConfiguredSender',
requestTimeoutMs: 3200,
dryRun: true,
devsms: {
baseUrl: 'http://configured-devsms:4000'
}
});
expect(mockGetEnvOptions).toHaveBeenCalledWith({}, cwd, inputEnv);
});
});
Loading
Loading