Skip to content
Merged
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
135 changes: 130 additions & 5 deletions DEVELOPMENT_JOBS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

This guide covers a local development workflow for the jobs stack:

- Postgres + `launchql-ext-jobs`
- Postgres + `launchql-database-jobs`
- LaunchQL API server
- `simple-email` function
- `send-email-link` function
- `knative-job-service`

It assumes:
Expand Down Expand Up @@ -102,19 +103,111 @@ This starts:

- `launchql-server` – GraphQL API server
- `simple-email` – Knative-style HTTP function
- `send-email-link` – Knative-style HTTP function
- `knative-job-service` – jobs runtime (callback server + worker + scheduler)

---

## 5. Enqueue a test job (simple-email)
### Switching dry run vs real Mailgun sending

By default, `docker-compose.jobs.yml` runs both email functions in dry-run mode (no real email is sent), and it uses placeholder Mailgun credentials unless you provide `MAILGUN_API_KEY` / `MAILGUN_KEY`.

Quick start commands:

Dry run:

```sh
docker compose -f docker-compose.jobs.yml up -d --build --force-recreate
```

Real sending (Mailgun):

```sh
MAILGUN_API_KEY="your-mailgun-key" MAILGUN_KEY="your-mailgun-key" SIMPLE_EMAIL_DRY_RUN=false SEND_EMAIL_LINK_DRY_RUN=false docker compose -f docker-compose.jobs.yml up -d --build --force-recreate
```

To use a real Mailgun key without editing `docker-compose.jobs.yml`, set these env vars before starting the stack (or put them in a local `.env` file in the `constructive/` directory). Don't commit your `.env`.

```sh
export MAILGUN_API_KEY="your-mailgun-key"
export MAILGUN_KEY="your-mailgun-key"
```

If you're not using `mg.constructive.io`, also override `MAILGUN_DOMAIN`, `MAILGUN_FROM`, and `MAILGUN_REPLY` (for example in the override file below) to match your Mailgun setup.

To actually send email (instead of dry-run), set these env vars (or put them in your local `.env`):

```sh
export SIMPLE_EMAIL_DRY_RUN=false
export SEND_EMAIL_LINK_DRY_RUN=false
```

Then recreate the stack so the new env is applied:

```sh
docker compose -f docker-compose.jobs.yml up -d --build --force-recreate
```

If you prefer not to export env vars, create a local override file (don't commit it) at `docker-compose.jobs.override.yml`:

```yml
services:
simple-email:
environment:
SIMPLE_EMAIL_DRY_RUN: "false"

send-email-link:
environment:
SEND_EMAIL_LINK_DRY_RUN: "false"
```

Start the stack with both files:

```sh
docker compose -f docker-compose.jobs.yml -f docker-compose.jobs.override.yml up -d --build --force-recreate
```

To switch back to dry-run, set `SIMPLE_EMAIL_DRY_RUN=true` and `SEND_EMAIL_LINK_DRY_RUN=true` (or delete the override file) and recreate again.

---

## 5. Ensure GraphQL host routing works for `send-email-link`

LaunchQL selects the API by the HTTP `Host` header using rows in `meta_public.domains`.

For local development, `app-svc-local` seeds `admin.localhost` as the admin API domain. `docker-compose.jobs.yml` adds a Docker network alias so other containers can resolve `admin.localhost` to the `launchql-server` container, and `send-email-link` uses:

- `GRAPHQL_URL=http://admin.localhost:3000/graphql`

Quick check from your host (should return JSON, not HTML):

```sh
curl -s -H 'Host: admin.localhost' \
-H 'Content-Type: application/json' \
-X POST http://localhost:3000/graphql \
--data '{"query":"query { __typename }"}'
```

If your GraphQL server requires auth, set `GRAPHQL_AUTH_TOKEN` before starting the jobs stack (it is passed through to the `send-email-link` container).

---

## 6. Enqueue a test job (simple-email)

With the jobs stack running, you can enqueue a test job from your host into the Postgres container:

First, grab a real `database_id` (required by `send-email-link`, optional for `simple-email`):

```sh
DBID="$(docker exec -i postgres psql -U postgres -d launchql -Atc 'SELECT id FROM collections_public.database ORDER BY created_at LIMIT 1;')"
echo "$DBID"
```

```sh
docker exec -it postgres \
psql -U postgres -d launchql -c "
SELECT app_jobs.add_job(
'00000000-0000-0000-0000-000000000001'::uuid,
'$DBID'::uuid,
'simple-email',
json_build_object(
'to', 'user@example.com',
Expand All @@ -129,7 +222,39 @@ You should then see the job picked up by `knative-job-service` and the email pay

---

## 6. Inspect logs and iterate
## 7. Enqueue a test job (`send-email-link`)

`send-email-link` queries GraphQL for site/database metadata, so it requires:

- The app/meta packages deployed in step 3 (`app-svc-local`, `db-meta`)
- A real `database_id` (use `$DBID` above)
- A GraphQL hostname that matches a seeded domain route (step 5)

With `SEND_EMAIL_LINK_DRY_RUN=true` (default in `docker-compose.jobs.yml`), enqueue a job:

```sh
docker exec -it postgres \
psql -U postgres -d launchql -c "
SELECT app_jobs.add_job(
'$DBID'::uuid,
'send-email-link',
json_build_object(
'email_type', 'invite_email',
'email', 'user@example.com',
'invite_token', 'invite123',
'sender_id', '00000000-0000-0000-0000-000000000000'
)::json
);
"
```

You should see a log like:

- `[send-email-link] DRY RUN email (skipping send) ...`

---

## 8. Inspect logs and iterate

To watch logs while you develop:

Expand All @@ -153,7 +278,7 @@ docker compose -f docker-compose.jobs.yml up --build

---

## 7. Stopping services
## 9. Stopping services

To stop only the jobs stack:

Expand Down
45 changes: 38 additions & 7 deletions docker-compose.jobs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ services:
ports:
- "3000:3000"
networks:
- constructive-net
constructive-net:
aliases:
# Let other containers call the admin API using the seeded domain route.
- admin.localhost

# Simple email function (Knative-style HTTP function)
simple-email:
Expand All @@ -43,11 +46,11 @@ services:
environment:
NODE_ENV: development
LOG_LEVEL: info
SIMPLE_EMAIL_DRY_RUN: "true"
SIMPLE_EMAIL_DRY_RUN: "${SIMPLE_EMAIL_DRY_RUN:-true}"
# Mailgun / email provider configuration for @launchql/postmaster
# Replace with real credentials for local testing.
MAILGUN_API_KEY: "change-me-mailgun-api-key"
MAILGUN_KEY: "change-me-mailgun-api-key"
MAILGUN_API_KEY: "${MAILGUN_API_KEY:-change-me-mailgun-api-key}"
MAILGUN_KEY: "${MAILGUN_KEY:-change-me-mailgun-api-key}"
MAILGUN_DOMAIN: "mg.constructive.io"
MAILGUN_FROM: "no-reply@mg.constructive.io"
MAILGUN_REPLY: "info@mg.constructive.io"
Expand All @@ -57,6 +60,33 @@ services:
networks:
- constructive-net

# Send email link function (invite, password reset, verification)
send-email-link:
container_name: send-email-link
image: constructive-launchql:dev
entrypoint: ["node", "functions/send-email-link/dist/index.js"]
environment:
NODE_ENV: development
LOG_LEVEL: info
DEFAULT_DATABASE_ID: "dbe"
# LaunchQL selects the API by Host header; use a seeded domain route.
GRAPHQL_URL: "http://admin.localhost:3000/graphql"
META_GRAPHQL_URL: "http://admin.localhost:3000/graphql"
# Optional: provide an existing API token (Bearer) if your server requires it.
GRAPHQL_AUTH_TOKEN: "${GRAPHQL_AUTH_TOKEN:-}"
# Mailgun / email provider configuration for @launchql/postmaster
MAILGUN_API_KEY: "${MAILGUN_API_KEY:-change-me-mailgun-api-key}"
MAILGUN_KEY: "${MAILGUN_KEY:-change-me-mailgun-api-key}"
MAILGUN_DOMAIN: "mg.constructive.io"
MAILGUN_FROM: "no-reply@mg.constructive.io"
MAILGUN_REPLY: "info@mg.constructive.io"
SEND_EMAIL_LINK_DRY_RUN: "${SEND_EMAIL_LINK_DRY_RUN:-true}"
ports:
# Expose function locally (optional)
- "8082:8080"
networks:
- constructive-net

# Jobs runtime: callback server + worker + scheduler
knative-job-service:
container_name: knative-job-service
Expand All @@ -65,6 +95,7 @@ services:
entrypoint: ["node", "jobs/knative-job-service/dist/run.js"]
depends_on:
- simple-email
- send-email-link
environment:
NODE_ENV: development

Expand All @@ -78,7 +109,7 @@ services:

# Worker configuration
JOBS_SUPPORT_ANY: "false"
JOBS_SUPPORTED: "simple-email"
JOBS_SUPPORTED: "simple-email,send-email-link"
HOSTNAME: "knative-job-service-1"

# Callback HTTP server (job completion callbacks)
Expand All @@ -92,8 +123,8 @@ services:

# Development-only map from task identifier -> function URL
# Used by @launchql/knative-job-worker when NODE_ENV !== 'production'.
# This lets the worker call the simple-email container directly in docker-compose.
INTERNAL_GATEWAY_DEVELOPMENT_MAP: '{"simple-email":"http://simple-email:8080"}'
# This lets the worker call the function containers directly in docker-compose.
INTERNAL_GATEWAY_DEVELOPMENT_MAP: '{"simple-email":"http://simple-email:8080","send-email-link":"http://send-email-link:8080"}'

ports:
- "8080:8080"
Expand Down
1 change: 1 addition & 0 deletions functions/send-email-link/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@launchql/mjml": "0.1.1",
"@launchql/postmaster": "0.1.4",
"@launchql/styled-email": "0.1.0",
"@pgpmjs/env": "workspace:^",
"graphql-request": "^7.1.2",
"graphql-tag": "^2.12.6"
}
Expand Down
62 changes: 56 additions & 6 deletions functions/send-email-link/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { GraphQLClient } from 'graphql-request';
import gql from 'graphql-tag';
import { generate } from '@launchql/mjml';
import { send } from '@launchql/postmaster';
import { parseEnvBoolean } from '@pgpmjs/env';

const isDryRun = parseEnvBoolean(process.env.SEND_EMAIL_LINK_DRY_RUN) ?? false;

const GetUser = gql`
query GetUser($userId: UUID!) {
Expand Down Expand Up @@ -84,13 +87,40 @@ export const sendEmailLink = async (
) => {
const { client, meta, databaseId } = context;

const validateForType = (): { missing?: string } | null => {
switch (params.email_type) {
case 'invite_email':
if (!params.invite_token || !params.sender_id) {
return { missing: 'invite_token_or_sender_id' };
}
return null;
case 'forgot_password':
if (!params.user_id || !params.reset_token) {
return { missing: 'user_id_or_reset_token' };
}
return null;
case 'email_verification':
if (!params.email_id || !params.verification_token) {
return { missing: 'email_id_or_verification_token' };
}
return null;
default:
return { missing: 'email_type' };
}
};

if (!params.email_type) {
return { missing: 'email_type' };
}
if (!params.email) {
return { missing: 'email' };
}

const typeValidation = validateForType();
if (typeValidation) {
return typeValidation;
}

const databaseInfo = await meta.request<any>(GetDatabaseInfo, {
databaseId
});
Expand Down Expand Up @@ -209,14 +239,25 @@ export const sendEmailLink = async (
}
});

await send({
to: params.email,
subject,
html
});
if (isDryRun) {
// eslint-disable-next-line no-console
console.log('[send-email-link] DRY RUN email (skipping send)', {
email_type: params.email_type,
email: params.email,
subject,
link
});
} else {
await send({
to: params.email,
subject,
html
});
}

return {
complete: true
complete: true,
...(isDryRun ? { dryRun: true } : null)
};
};

Expand Down Expand Up @@ -251,3 +292,12 @@ app.post('*', async (req: any, res: any, next: any) => {

export default app;

// When executed directly (e.g. via `node dist/index.js`), start an HTTP server.
if (require.main === module) {
const port = Number(process.env.PORT ?? 8080);
// @launchql/knative-job-fn exposes a .listen method that delegates to the Express app
(app as any).listen(port, () => {
// eslint-disable-next-line no-console
console.log(`[send-email-link] listening on port ${port}`);
});
}
3 changes: 2 additions & 1 deletion functions/simple-email/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
},
"dependencies": {
"@launchql/knative-job-fn": "workspace:^",
"@launchql/postmaster": "0.1.4"
"@launchql/postmaster": "0.1.4",
"@pgpmjs/env": "workspace:^"
}
}
8 changes: 2 additions & 6 deletions functions/simple-email/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import app from '@launchql/knative-job-fn';
import { parseEnvBoolean } from '@pgpmjs/env';
import { send as sendEmail } from '@launchql/postmaster';

type SimpleEmailPayload = {
Expand All @@ -24,12 +25,7 @@ const getRequiredField = (
return value;
};

const isDryRun = (() => {
const val = process.env.SIMPLE_EMAIL_DRY_RUN;
if (!val) return false;
const s = val.toLowerCase();
return s === 'true' || s === '1' || s === 'yes' || s === 'y';
})();
const isDryRun = parseEnvBoolean(process.env.SIMPLE_EMAIL_DRY_RUN) ?? false;

app.post('*', async (req: any, res: any, next: any) => {
try {
Expand Down
Loading