mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
feat(yucca-admin-api): send invite emails from the allowlist flow (#492)
This commit is contained in:
@@ -20,3 +20,9 @@ OP_ACCOUNT="team-futo.1password.com"
|
|||||||
# POLAR_WEBHOOK_SECRET=
|
# POLAR_WEBHOOK_SECRET=
|
||||||
# POLAR_PRODUCT_ID=d9bc9054-eb91-46c9-846e-dd5bbb8f0041
|
# POLAR_PRODUCT_ID=d9bc9054-eb91-46c9-846e-dd5bbb8f0041
|
||||||
# POLAR_APPLY_DISCOUNT_ID=391d9f04-889a-44ff-95f1-98cee8a5b0db
|
# POLAR_APPLY_DISCOUNT_ID=391d9f04-889a-44ff-95f1-98cee8a5b0db
|
||||||
|
|
||||||
|
# Postmark: defaults target the local mock (compose/Tilt). Set both to send
|
||||||
|
# real email from a dev machine.
|
||||||
|
# POSTMARK_API_URL="https://api.postmarkapp.com"
|
||||||
|
# POSTMARK_SERVER_TOKEN="op://yucca_tf_staging/POSTMARK_SERVER_TOKEN/password"
|
||||||
|
# EMAIL_FROM_ADDRESS="FUTO Backups <noreply@backups.futo.cloud>"
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
run: sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc || true
|
run: sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc || true
|
||||||
|
|
||||||
# Cluster bring-up and the workspace install touch none of the same files,
|
# Cluster bring-up and the workspace install touch none of the same files,
|
||||||
# so they overlap. Only @common/server is built: the suites run from
|
# so they overlap. Only the @common/* libs are built: the suites run from
|
||||||
# TypeScript source via ts-jest, so `mise prepare`'s other packages are
|
# TypeScript source via ts-jest, so `mise prepare`'s other packages are
|
||||||
# never loaded here.
|
# never loaded here.
|
||||||
- name: Stand up k3d infra, install deps and build shared libs
|
- name: Stand up k3d infra, install deps and build shared libs
|
||||||
@@ -87,6 +87,7 @@ jobs:
|
|||||||
infra=$!
|
infra=$!
|
||||||
mise run install:frozen
|
mise run install:frozen
|
||||||
mise run common:server:build
|
mise run common:server:build
|
||||||
|
mise run common:emails:build
|
||||||
wait "$infra" || { echo "::error::k3d infra failed"; cat /tmp/k3d-infra.log; exit 1; }
|
wait "$infra" || { echo "::error::k3d infra failed"; cat /tmp/k3d-infra.log; exit 1; }
|
||||||
echo "::group::k3d infra log"; cat /tmp/k3d-infra.log; echo "::endgroup::"
|
echo "::group::k3d infra log"; cat /tmp/k3d-infra.log; echo "::endgroup::"
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export TOPOLOGY_FILE=${TOPOLOGY_FILE:-./topology.dev.json}
|
|||||||
export LEGACY_SITE_CODE=${LEGACY_SITE_CODE:-local}
|
export LEGACY_SITE_CODE=${LEGACY_SITE_CODE:-local}
|
||||||
export LEGACY_STORAGE_CLUSTER_CODE=${LEGACY_STORAGE_CLUSTER_CODE:-local-dev}
|
export LEGACY_STORAGE_CLUSTER_CODE=${LEGACY_STORAGE_CLUSTER_CODE:-local-dev}
|
||||||
|
|
||||||
|
# Invite emails: the compose mock Postmark (delivering into Mailpit on :8025).
|
||||||
|
# Point POSTMARK_API_URL + a real token at Postmark to test actual delivery.
|
||||||
|
export POSTMARK_API_URL=${POSTMARK_API_URL:-http://localhost:8093}
|
||||||
|
export POSTMARK_SERVER_TOKEN=${POSTMARK_SERVER_TOKEN:-dev token}
|
||||||
|
export WEB_BASE_URL=${WEB_BASE_URL:-http://localhost:5173}
|
||||||
|
|
||||||
export OIDC_ADMIN_ISSUER=${OIDC_ISSUER:-http://localhost:8092}
|
export OIDC_ADMIN_ISSUER=${OIDC_ISSUER:-http://localhost:8092}
|
||||||
export OIDC_ADMIN_CLIENT_ID=${OIDC_CLIENT_ID:-client ID}
|
export OIDC_ADMIN_CLIENT_ID=${OIDC_CLIENT_ID:-client ID}
|
||||||
export OIDC_ADMIN_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-client secret}
|
export OIDC_ADMIN_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-client secret}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ followed by `tilt:ci-e2e` (the apps the e2e suites touch). Ceph converging outwe
|
|||||||
in the integration job, so that job dropped it and its S3-backed suites moved to the e2e one.
|
in the integration job, so that job dropped it and its S3-backed suites moved to the e2e one.
|
||||||
|
|
||||||
Ports: `5173` web · `3020` yucca-api · `3030` yucca-admin-api · `3010` michael ·
|
Ports: `5173` web · `3020` yucca-api · `3030` yucca-admin-api · `3010` michael ·
|
||||||
`8092` mock-oidc · `9000` ceph rgw · `8428` victoria-metrics · `9428` victoria-logs.
|
`8092` mock-oidc · `8025` mailpit · `8093` mock-postmark · `9000` ceph rgw · `8428` victoria-metrics · `9428` victoria-logs.
|
||||||
|
|
||||||
### Infrastructure commands
|
### Infrastructure commands
|
||||||
|
|
||||||
@@ -110,7 +110,9 @@ Zod-validated `env.ts`, JWT auth guards via `@AuthRoute()`, OTel from `@common/s
|
|||||||
| `yucca-metrics-worker` | NestJS | 5-min cron: RadosGW usage → meter tables → per-connection rollup (`connectionMetrics`, billing floor), OTel gauges. |
|
| `yucca-metrics-worker` | NestJS | 5-min cron: RadosGW usage → meter tables → per-connection rollup (`connectionMetrics`, billing floor), OTel gauges. |
|
||||||
| `redis` (valkey) | | Shared platform cache (ephemeral; keys `yucca:<service>:<purpose>:*`). Primary-region only. |
|
| `redis` (valkey) | | Shared platform cache (ephemeral; keys `yucca:<service>:<purpose>:*`). Primary-region only. |
|
||||||
| `mock-oidc-provider` | Node | Dev/test OIDC IdP (code + device flow). |
|
| `mock-oidc-provider` | Node | Dev/test OIDC IdP (code + device flow). |
|
||||||
| `common` (`@common/server`) | TS lib | OTel init, pino repository, **feature-flag registry + connection-type registry**. |
|
| `mock-postmark-provider` | Node | Dev/test Postmark API mock; delivers into the Mailpit inbox. |
|
||||||
|
| `common` (`@common/server`) | TS lib | OTel init, pino repository, **feature-flag registry + connection-type registry**, Postmark `EmailRepository` (`@common/server/email`). |
|
||||||
|
| `emails` (`@common/emails`) | Svelte lib | Transactional email templates (better-svelte-email, web theme), prebuilt to JS for the NestJS apps. See `docs/email.md`. |
|
||||||
|
|
||||||
**Frontend** (`packages/web`): SvelteKit 5 + Tailwind 4, `@immich/ui`, lingui i18n
|
**Frontend** (`packages/web`): SvelteKit 5 + Tailwind 4, `@immich/ui`, lingui i18n
|
||||||
(`mise web:lingui:*`; compiled locales are generated, not edited), generated API client.
|
(`mise web:lingui:*`; compiled locales are generated, not edited), generated API client.
|
||||||
|
|||||||
@@ -216,9 +216,11 @@ docker_build(
|
|||||||
live_update=[
|
live_update=[
|
||||||
sync('./packages/yucca-admin-api', '/app/packages/yucca-admin-api'),
|
sync('./packages/yucca-admin-api', '/app/packages/yucca-admin-api'),
|
||||||
sync('./packages/common', '/app/packages/common'),
|
sync('./packages/common', '/app/packages/common'),
|
||||||
|
sync('./packages/emails', '/app/packages/emails'),
|
||||||
# yucca-admin-api/src/schema is a symlink into yucca-api; keep it synced.
|
# yucca-admin-api/src/schema is a symlink into yucca-api; keep it synced.
|
||||||
sync('./packages/yucca-api', '/app/packages/yucca-api'),
|
sync('./packages/yucca-api', '/app/packages/yucca-api'),
|
||||||
run('cd /app && pnpm --filter @common/server build', trigger=['./packages/common/src']),
|
run('cd /app && pnpm --filter @common/server build', trigger=['./packages/common/src']),
|
||||||
|
run('cd /app && pnpm --filter @common/emails build', trigger=['./packages/emails/src']),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -333,7 +335,7 @@ APP_WIRING = {
|
|||||||
# calls it at boot. Tilt builds an image only when ready to deploy, so that
|
# calls it at boot. Tilt builds an image only when ready to deploy, so that
|
||||||
# edge parked this build (and web's) behind Rook-Ceph instead of alongside.
|
# edge parked this build (and web's) behind Rook-Ceph instead of alongside.
|
||||||
'yucca-api': {'build': 'yucca-api', 'deps': ['yucca-database', 'yucca-mock-oidc', 'yucca-topology'], 'dev_env': True, 'dev_keypair': True},
|
'yucca-api': {'build': 'yucca-api', 'deps': ['yucca-database', 'yucca-mock-oidc', 'yucca-topology'], 'dev_env': True, 'dev_keypair': True},
|
||||||
'yucca-admin-api': {'build': 'yucca-admin-api', 'deps': ['yucca-database', 'yucca-mock-oidc', 'yucca-topology'], 'dev_keypair': True},
|
'yucca-admin-api': {'build': 'yucca-admin-api', 'deps': ['yucca-database', 'yucca-mock-oidc', 'yucca-topology'], 'dev_env': True, 'dev_keypair': True},
|
||||||
'yucca-metrics-worker': {'build': 'yucca-metrics-worker', 'deps': ['yucca-database', 'yucca-metrics-object-user', 'yucca-topology'], 'dev_env': True},
|
'yucca-metrics-worker': {'build': 'yucca-metrics-worker', 'deps': ['yucca-database', 'yucca-metrics-object-user', 'yucca-topology'], 'dev_env': True},
|
||||||
# Likewise: the dev server reaches yucca-api per request, not at boot.
|
# Likewise: the dev server reaches yucca-api per request, not at boot.
|
||||||
'yucca-web': {'build': 'web', 'deps': []},
|
'yucca-web': {'build': 'web', 'deps': []},
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ oidcLogoutRedirectUri: http://localhost:3030
|
|||||||
secretData:
|
secretData:
|
||||||
OIDC_ADMIN_CLIENT_ID: "client ID"
|
OIDC_ADMIN_CLIENT_ID: "client ID"
|
||||||
OIDC_ADMIN_CLIENT_SECRET: "client secret"
|
OIDC_ADMIN_CLIENT_SECRET: "client secret"
|
||||||
|
# Any value satisfies the mock Postmark; prod gets the real token from the
|
||||||
|
# TF-provisioned Secret.
|
||||||
|
POSTMARK_SERVER_TOKEN: "dev token"
|
||||||
|
|
||||||
# OPT-IN dev signing key for CLI session JWTs. The project's well-known
|
# OPT-IN dev signing key for CLI session JWTs. The project's well-known
|
||||||
# local-dev ES256 keypair (the same one committed in .mise/tasks/*/env and the
|
# local-dev ES256 keypair (the same one committed in .mise/tasks/*/env and the
|
||||||
@@ -79,6 +82,13 @@ env:
|
|||||||
value: http://victoria-metrics:8428/opentelemetry/v1/metrics
|
value: http://victoria-metrics:8428/opentelemetry/v1/metrics
|
||||||
- name: OTEL_LOGGING
|
- name: OTEL_LOGGING
|
||||||
value: http://victoria-logs:9428/insert/opentelemetry/v1/logs
|
value: http://victoria-logs:9428/insert/opentelemetry/v1/logs
|
||||||
|
# Invite emails land in the dev mock (→ Mailpit); the prod env list in the
|
||||||
|
# base HelmRelease omits this so the real Postmark default applies.
|
||||||
|
- name: POSTMARK_API_URL
|
||||||
|
value: http://yucca-mock-postmark:8093
|
||||||
|
# Invite links resolve via the Tilt web port-forward.
|
||||||
|
- name: WEB_BASE_URL
|
||||||
|
value: http://localhost:5173
|
||||||
|
|
||||||
# Probes keep `tilt ci`/Flux honest: without them a crash-looping dev process
|
# Probes keep `tilt ci`/Flux honest: without them a crash-looping dev process
|
||||||
# still counts as Ready (this masked two real bugs). The startupProbe budgets
|
# still counts as Ready (this masked two real bugs). The startupProbe budgets
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Email
|
||||||
|
|
||||||
|
Yucca sends transactional email through [Postmark](https://postmarkapp.com/). The first (and so
|
||||||
|
far only) sender is the invite flow in yucca-admin-api; anything that later needs to email a user
|
||||||
|
builds on the same three pieces.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
| Piece | Where | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| `@common/emails` | `packages/emails` | Svelte email templates (better-svelte-email, Tailwind 4) styled after the web UI's `@immich/ui` theme, prebuilt to plain JS. Exposes `render*Email(props) → { subject, htmlBody, textBody }`. |
|
||||||
|
| `EmailRepository` | `@common/server/email` | Postmark HTTP client over global `fetch` (`POST /email`, `POST /email/batch`, ≤500 per batch). Stamps `From`, the `outbound` message stream, and an optional `Tag`. |
|
||||||
|
| Callers | e.g. `AllowlistService` | Render a template, hand the result to `EmailRepository`, record the outcome. |
|
||||||
|
|
||||||
|
Services wire `EmailRepository` into their `providers` array like any other repository; templates
|
||||||
|
are imported as functions. The renderer never runs in the request path of the web app — emails are
|
||||||
|
rendered inside the NestJS process from the prebuilt `@common/emails` dist.
|
||||||
|
|
||||||
|
**Degraded mode:** without `POSTMARK_SERVER_TOKEN` the repository logs and skips every send.
|
||||||
|
Environments that don't care about email keep working; nothing crashes at boot.
|
||||||
|
|
||||||
|
## Invite flow
|
||||||
|
|
||||||
|
`POST /allowlist/invite` and `POST /allowlist/invite-batch` (yucca-admin-api, driven by yuctl)
|
||||||
|
email every affected entry whose `userAllowlist.inviteEmailSentAt` is still null, then stamp it on
|
||||||
|
success. Re-running an invite is therefore a safe retry that only reaches the not-yet-emailed
|
||||||
|
entries; a rejected address stays null and is retried next time. The email carries the invite code
|
||||||
|
and links to `${WEB_BASE_URL}/login/invite`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Env var | Meaning | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `POSTMARK_SERVER_TOKEN` | Postmark server API token (secret) | unset → log-and-skip |
|
||||||
|
| `POSTMARK_API_URL` | Postmark endpoint; dev points it at the mock | `https://api.postmarkapp.com` |
|
||||||
|
| `EMAIL_FROM_ADDRESS` | `From` header | `FUTO Backups <noreply@backups.futo.cloud>` |
|
||||||
|
| `WEB_BASE_URL` | Base for links in emails (yucca-admin-api) | `http://localhost:5173` |
|
||||||
|
|
||||||
|
The boundary rule from [feature-flags.md](feature-flags.md) applies: these are deployment config
|
||||||
|
(env/Secret/cluster-settings), not feature flags. In staging/prod the token rides in the
|
||||||
|
TF-provisioned `yucca-admin-api` Secret; the from-address and `WEB_BASE_URL` come from the base
|
||||||
|
HelmRelease + cluster-settings.
|
||||||
|
|
||||||
|
## Local dev
|
||||||
|
|
||||||
|
Nothing leaves the machine. The app speaks the real Postmark wire protocol to an in-repo mock,
|
||||||
|
which delivers into a [Mailpit](https://mailpit.axllent.org/) inbox:
|
||||||
|
|
||||||
|
- **compose (`mise dev`)**: `mock-postmark-provider` on `localhost:8093`, Mailpit UI on
|
||||||
|
`http://localhost:8025`. The yucca-admin-api dev env defaults point at the mock.
|
||||||
|
- **k3d/Tilt**: `yucca-mock-postmark` + `yucca-mailpit` (dev-only HelmReleases under
|
||||||
|
`kubernetes/apps/dev/local`), same ports via the Tilt port-forwards.
|
||||||
|
- **e2e/tests**: assert through Mailpit's REST API (`GET /api/v1/messages`, search, message body);
|
||||||
|
unit/integration tests mock or override `EmailRepository` instead.
|
||||||
|
|
||||||
|
To send *real* email from a dev machine, set `POSTMARK_API_URL` + `POSTMARK_SERVER_TOKEN` in
|
||||||
|
`.env` (see `.env.example`).
|
||||||
|
|
||||||
|
## Adding a template
|
||||||
|
|
||||||
|
1. Add `packages/emails/src/emails/<name>.svelte` (compose with `src/lib/layout.svelte` and
|
||||||
|
`@better-svelte-email/components`; Tailwind classes only — the renderer inlines them).
|
||||||
|
2. Export a typed `render<Name>Email()` from `packages/emails/src/index.ts`.
|
||||||
|
3. Preview while iterating: `pnpm --filter @common/emails preview`.
|
||||||
|
4. Call it from a service and pass the result to `EmailRepository.send`/`sendBatch` with a `tag`.
|
||||||
|
|
||||||
|
Email HTML is its own dialect: no CSS variables, no `oklch()`, table-friendly markup — which is
|
||||||
|
why `src/theme.ts` mirrors the `@immich/ui` palette as literal hex and templates never import web
|
||||||
|
components directly.
|
||||||
@@ -42,7 +42,7 @@ RUN pnpm install --frozen-lockfile
|
|||||||
|
|
||||||
# 2. Copy sources, build workspace libs yucca-admin-api imports at runtime
|
# 2. Copy sources, build workspace libs yucca-admin-api imports at runtime
|
||||||
COPY . ./
|
COPY . ./
|
||||||
RUN pnpm --filter @common/server build
|
RUN pnpm --filter @common/server build && pnpm --filter @common/emails build
|
||||||
|
|
||||||
EXPOSE 3030
|
EXPOSE 3030
|
||||||
CMD ["pnpm", "--filter", "yucca-admin-api", "start:dev"]
|
CMD ["pnpm", "--filter", "yucca-admin-api", "start:dev"]
|
||||||
|
|||||||
@@ -838,6 +838,88 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/settings": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listSettings",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SettingsListResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Settings"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/settings/{scope}": {
|
||||||
|
"put": {
|
||||||
|
"operationId": "setSettings",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "scope",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SettingsValueDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SettingsEntryResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Settings"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"operationId": "deleteSettings",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "scope",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Settings"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/features": {
|
"/api/features": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listFeatures",
|
"operationId": "listFeatures",
|
||||||
@@ -1268,6 +1350,14 @@
|
|||||||
"worm": {
|
"worm": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"siteCode": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Stable internal site code"
|
||||||
|
},
|
||||||
|
"storageClusterCode": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Stable, globally unique internal storage cluster code"
|
||||||
|
},
|
||||||
"connectionId": {
|
"connectionId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1285,6 +1375,8 @@
|
|||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
"worm",
|
"worm",
|
||||||
|
"siteCode",
|
||||||
|
"storageClusterCode",
|
||||||
"connectionId",
|
"connectionId",
|
||||||
"connectionType",
|
"connectionType",
|
||||||
"user",
|
"user",
|
||||||
@@ -1322,6 +1414,10 @@
|
|||||||
"worm": {
|
"worm": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"site": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Internal site code to place the repository in; defaults to the topology default site"
|
||||||
|
},
|
||||||
"connectionType": {
|
"connectionType": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -1412,6 +1508,10 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"inviteEmailSentAt": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -1508,6 +1608,63 @@
|
|||||||
"count"
|
"count"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"SettingsValueDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"restic_pack_size_mib": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"connections_math": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Client-evaluated expression: integers, cores, min, max, + - * /"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SettingsEntryDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"scope": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "'global', 'site:<code>' or 'cluster:<code>'"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"$ref": "#/components/schemas/SettingsValueDto"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"scope",
|
||||||
|
"value",
|
||||||
|
"updatedAt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"SettingsListResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"settings": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SettingsEntryDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"settings"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"SettingsEntryResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"entry": {
|
||||||
|
"$ref": "#/components/schemas/SettingsEntryDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"entry"
|
||||||
|
]
|
||||||
|
},
|
||||||
"FeatureFlagDefDto": {
|
"FeatureFlagDefDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@common/emails": "workspace:^",
|
||||||
"@common/server": "workspace:^",
|
"@common/server": "workspace:^",
|
||||||
"@immich/sql-tools": "catalog:",
|
"@immich/sql-tools": "catalog:",
|
||||||
"@nestjs/common": "catalog:",
|
"@nestjs/common": "catalog:",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { EmailRepository } from '@common/server/email';
|
||||||
import { LoggerRepository, LoggingInterceptor, OtelModule, WideContextRepository } from '@common/server/otel';
|
import { LoggerRepository, LoggingInterceptor, OtelModule, WideContextRepository } from '@common/server/otel';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
@@ -61,6 +62,7 @@ export const controllers = [
|
|||||||
export const providers = [
|
export const providers = [
|
||||||
WideContextRepository,
|
WideContextRepository,
|
||||||
LoggerRepository,
|
LoggerRepository,
|
||||||
|
EmailRepository,
|
||||||
DatabaseRepository,
|
DatabaseRepository,
|
||||||
DatabaseService,
|
DatabaseService,
|
||||||
OidcRepository,
|
OidcRepository,
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export class AllowlistEntryDto {
|
|||||||
@ApiProperty({ type: 'string', required: false, nullable: true })
|
@ApiProperty({ type: 'string', required: false, nullable: true })
|
||||||
inviteUsedAt!: Date | null;
|
inviteUsedAt!: Date | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'string', required: false, nullable: true })
|
||||||
|
inviteEmailSentAt!: Date | null;
|
||||||
|
|
||||||
@ApiProperty({ type: 'string' })
|
@ApiProperty({ type: 'string' })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const schema = z.object({
|
|||||||
LEGACY_SITE_CODE: z.string(),
|
LEGACY_SITE_CODE: z.string(),
|
||||||
LEGACY_STORAGE_CLUSTER_CODE: z.string(),
|
LEGACY_STORAGE_CLUSTER_CODE: z.string(),
|
||||||
|
|
||||||
|
WEB_BASE_URL: z.url().default('http://localhost:5173'),
|
||||||
|
|
||||||
OIDC_ADMIN_ISSUER: z.url().transform((url) => new URL(url)),
|
OIDC_ADMIN_ISSUER: z.url().transform((url) => new URL(url)),
|
||||||
OIDC_ADMIN_CLIENT_ID: z.string(),
|
OIDC_ADMIN_CLIENT_ID: z.string(),
|
||||||
OIDC_ADMIN_CLIENT_SECRET: z.string(),
|
OIDC_ADMIN_CLIENT_SECRET: z.string(),
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { AllowlistService } from 'src/services/allowlist.service';
|
||||||
|
import { Mocks, newMocks } from '../../test/mocks';
|
||||||
|
|
||||||
|
const row = (overrides: object = {}) => ({
|
||||||
|
id: 'entry-1',
|
||||||
|
email: 'user@example.com',
|
||||||
|
inviteCode: 'ABC123DEF4',
|
||||||
|
invited: true,
|
||||||
|
inviteUsed: false,
|
||||||
|
inviteUsedAt: null,
|
||||||
|
inviteEmailSentAt: null,
|
||||||
|
createdAt: new Date('2026-08-01'),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(AllowlistService.name, () => {
|
||||||
|
let mocks: Mocks;
|
||||||
|
let sut: AllowlistService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks = newMocks();
|
||||||
|
sut = new AllowlistService(mocks.allowlist as never, mocks.email as never, mocks.logger);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invite', () => {
|
||||||
|
it('emails newly invited entries and records the send', async () => {
|
||||||
|
const created = row();
|
||||||
|
const sent = row({ inviteEmailSentAt: new Date('2026-08-19') });
|
||||||
|
mocks.allowlist.create.mockResolvedValue(created as never);
|
||||||
|
mocks.allowlist.update.mockResolvedValue(sent as never);
|
||||||
|
mocks.email.sendBatch.mockResolvedValue([{ to: created.email, errorCode: 0, message: 'OK' }]);
|
||||||
|
|
||||||
|
const { items } = await sut.invite({ emails: ['User@Example.com'] });
|
||||||
|
|
||||||
|
expect(mocks.email.sendBatch).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({ to: 'user@example.com', tag: 'invite', subject: "You're invited to FUTO Backups" }),
|
||||||
|
]);
|
||||||
|
const [messages] = mocks.email.sendBatch.mock.calls[0];
|
||||||
|
expect(messages[0].htmlBody).toContain('ABC123DEF4');
|
||||||
|
expect(messages[0].htmlBody).toContain('/login/invite');
|
||||||
|
expect(messages[0].textBody).toContain('ABC123DEF4');
|
||||||
|
expect(mocks.allowlist.update).toHaveBeenCalledWith('entry-1', { inviteEmailSentAt: expect.any(Date) });
|
||||||
|
expect(items).toEqual([sent]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not resend to entries that already got their invite email', async () => {
|
||||||
|
const delivered = row({ inviteEmailSentAt: new Date('2026-08-02') });
|
||||||
|
mocks.allowlist.getByEmail.mockResolvedValue(delivered as never);
|
||||||
|
|
||||||
|
const { items } = await sut.invite({ emails: ['user@example.com'] });
|
||||||
|
|
||||||
|
expect(mocks.email.sendBatch).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.allowlist.update).not.toHaveBeenCalled();
|
||||||
|
expect(items).toEqual([delivered]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps rejected sends retryable', async () => {
|
||||||
|
const created = row();
|
||||||
|
mocks.allowlist.create.mockResolvedValue(created as never);
|
||||||
|
mocks.email.sendBatch.mockResolvedValue([{ to: created.email, errorCode: 406, message: 'Inactive recipient' }]);
|
||||||
|
|
||||||
|
const { items } = await sut.invite({ emails: ['user@example.com'] });
|
||||||
|
|
||||||
|
expect(mocks.allowlist.update).not.toHaveBeenCalled();
|
||||||
|
expect(items).toEqual([created]);
|
||||||
|
expect(mocks.logger.warn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns entries unchanged when the batch send fails outright', async () => {
|
||||||
|
const created = row();
|
||||||
|
mocks.allowlist.create.mockResolvedValue(created as never);
|
||||||
|
mocks.email.sendBatch.mockRejectedValue(new Error('postmark down'));
|
||||||
|
|
||||||
|
const { items } = await sut.invite({ emails: ['user@example.com'] });
|
||||||
|
|
||||||
|
expect(items).toEqual([created]);
|
||||||
|
expect(mocks.logger.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inviteBatch', () => {
|
||||||
|
it('invites the oldest staged entries and emails them', async () => {
|
||||||
|
const staged = row({ invited: false });
|
||||||
|
const invited = row();
|
||||||
|
const sent = row({ inviteEmailSentAt: new Date('2026-08-19') });
|
||||||
|
mocks.allowlist.oldestStaged.mockResolvedValue([staged] as never);
|
||||||
|
mocks.allowlist.update.mockResolvedValueOnce(invited as never).mockResolvedValueOnce(sent as never);
|
||||||
|
mocks.email.sendBatch.mockResolvedValue([{ to: staged.email, errorCode: 0, message: 'OK' }]);
|
||||||
|
|
||||||
|
const { items } = await sut.inviteBatch({ count: 1 });
|
||||||
|
|
||||||
|
expect(mocks.allowlist.update).toHaveBeenNthCalledWith(1, 'entry-1', { invited: true });
|
||||||
|
expect(mocks.allowlist.update).toHaveBeenNthCalledWith(2, 'entry-1', { inviteEmailSentAt: expect.any(Date) });
|
||||||
|
expect(items).toEqual([sent]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { renderInviteEmail } from '@common/emails';
|
||||||
|
import { EmailMessage, EmailRepository } from '@common/server/email';
|
||||||
|
import { LoggerRepository } from '@common/server/otel';
|
||||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
AllowlistAddRequestDto,
|
AllowlistAddRequestDto,
|
||||||
@@ -9,13 +12,18 @@ import {
|
|||||||
AllowlistListQueryDto,
|
AllowlistListQueryDto,
|
||||||
AllowlistListResponseDto,
|
AllowlistListResponseDto,
|
||||||
} from 'src/dto/allowlist.dto';
|
} from 'src/dto/allowlist.dto';
|
||||||
|
import { env } from 'src/env';
|
||||||
import { UserAllowlistRepository } from 'src/repositories/userAllowlist.repository';
|
import { UserAllowlistRepository } from 'src/repositories/userAllowlist.repository';
|
||||||
import { generateInviteCode } from 'src/utils/invite-code';
|
import { generateInviteCode } from 'src/utils/invite-code';
|
||||||
import { resolveLimit } from 'src/utils/pagination';
|
import { resolveLimit } from 'src/utils/pagination';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AllowlistService {
|
export class AllowlistService {
|
||||||
constructor(private readonly allowlist: UserAllowlistRepository) {}
|
constructor(
|
||||||
|
private readonly allowlist: UserAllowlistRepository,
|
||||||
|
private readonly email: EmailRepository,
|
||||||
|
private readonly logger: LoggerRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
list(query: AllowlistListQueryDto): Promise<AllowlistListResponseDto> {
|
list(query: AllowlistListQueryDto): Promise<AllowlistListResponseDto> {
|
||||||
return this.allowlist.list({ cursor: query.cursor, limit: resolveLimit(query.limit) });
|
return this.allowlist.list({ cursor: query.cursor, limit: resolveLimit(query.limit) });
|
||||||
@@ -54,7 +62,7 @@ export class AllowlistService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { items };
|
return { items: await this.sendInviteEmails(items) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async inviteBatch(dto: AllowlistInviteBatchRequestDto): Promise<AllowlistEntriesResponseDto> {
|
async inviteBatch(dto: AllowlistInviteBatchRequestDto): Promise<AllowlistEntriesResponseDto> {
|
||||||
@@ -65,7 +73,46 @@ export class AllowlistService {
|
|||||||
items.push(await this.allowlist.update(entry.id, { invited: true }));
|
items.push(await this.allowlist.update(entry.id, { invited: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { items };
|
return { items: await this.sendInviteEmails(items) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendInviteEmails(items: AllowlistEntryDto[]): Promise<AllowlistEntryDto[]> {
|
||||||
|
const pending = items.filter((entry) => !entry.inviteEmailSentAt);
|
||||||
|
if (pending.length === 0) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages: EmailMessage[] = [];
|
||||||
|
for (const entry of pending) {
|
||||||
|
const inviteUrl = new URL('/login/invite', env.WEB_BASE_URL).href;
|
||||||
|
messages.push({
|
||||||
|
to: entry.email,
|
||||||
|
tag: 'invite',
|
||||||
|
...(await renderInviteEmail({ inviteCode: entry.inviteCode, inviteUrl })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await this.email.sendBatch(messages).catch((error: unknown) => {
|
||||||
|
this.logger.error(error, 'Failed to send invite emails');
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
if (!results) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = new Map<string, AllowlistEntryDto>();
|
||||||
|
for (const [index, result] of results.entries()) {
|
||||||
|
const entry = pending[index];
|
||||||
|
if (result.errorCode === 0) {
|
||||||
|
updated.set(entry.id, await this.allowlist.update(entry.id, { inviteEmailSentAt: new Date() }));
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
{ email: entry.email, errorCode: result.errorCode },
|
||||||
|
`Invite email rejected: ${result.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items.map((entry) => updated.get(entry.id) ?? entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createEntry(email: string, invited: boolean) {
|
private async createEntry(email: string, invited: boolean) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { EmailMessage, EmailRepository } from '@common/server/email';
|
||||||
import { MetricService } from '@common/server/otel';
|
import { MetricService } from '@common/server/otel';
|
||||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
@@ -12,6 +13,9 @@ const authCookie = ['yucca-admin-sub=admin', 'yucca-admin-access-token=token'];
|
|||||||
|
|
||||||
describe('AllowlistController (e2e)', () => {
|
describe('AllowlistController (e2e)', () => {
|
||||||
let app: INestApplication<App>;
|
let app: INestApplication<App>;
|
||||||
|
const sendBatch = jest.fn((messages: EmailMessage[]) =>
|
||||||
|
Promise.resolve(messages.map((message) => ({ to: message.to, errorCode: 0, message: 'OK' }))),
|
||||||
|
);
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
@@ -23,6 +27,8 @@ describe('AllowlistController (e2e)', () => {
|
|||||||
.useValue(newMetricServiceMock())
|
.useValue(newMetricServiceMock())
|
||||||
.overrideProvider(OidcRepository)
|
.overrideProvider(OidcRepository)
|
||||||
.useValue({ onModuleInit: jest.fn(), fetchUserInfo: jest.fn().mockResolvedValue({ sub: 'admin' }) })
|
.useValue({ onModuleInit: jest.fn(), fetchUserInfo: jest.fn().mockResolvedValue({ sub: 'admin' }) })
|
||||||
|
.overrideProvider(EmailRepository)
|
||||||
|
.useValue({ send: jest.fn(), sendBatch })
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
@@ -37,6 +43,7 @@ describe('AllowlistController (e2e)', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await testUtils.resetDatabase();
|
await testUtils.resetDatabase();
|
||||||
|
sendBatch.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /allowlist', () => {
|
describe('GET /allowlist', () => {
|
||||||
@@ -165,6 +172,41 @@ describe('AllowlistController (e2e)', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emails invited entries and records the send', async () => {
|
||||||
|
const { body } = await request(app.getHttpServer())
|
||||||
|
.post('/api/allowlist/invite')
|
||||||
|
.set('Cookie', authCookie)
|
||||||
|
.send({ emails: ['fresh@example.com'] })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(sendBatch).toHaveBeenCalledTimes(1);
|
||||||
|
const [messages] = sendBatch.mock.calls[0];
|
||||||
|
expect(messages[0]).toEqual(
|
||||||
|
expect.objectContaining({ to: 'fresh@example.com', tag: 'invite', subject: "You're invited to FUTO Backups" }),
|
||||||
|
);
|
||||||
|
expect(messages[0].htmlBody).toContain(body.items[0].inviteCode);
|
||||||
|
expect(body.items[0].inviteEmailSentAt).toEqual(expect.any(String));
|
||||||
|
await expect(testUtils.getAllowlistEntry('fresh@example.com')).resolves.toEqual(
|
||||||
|
expect.objectContaining({ inviteEmailSentAt: expect.any(Date) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not resend to entries that already got their invite email', async () => {
|
||||||
|
await testUtils.createAllowlistEntry({
|
||||||
|
email: 'already@example.com',
|
||||||
|
invited: true,
|
||||||
|
inviteEmailSentAt: new Date('2026-08-01'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/allowlist/invite')
|
||||||
|
.set('Cookie', authCookie)
|
||||||
|
.send({ emails: ['already@example.com'] })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(sendBatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('leaves already-invited entries untouched', async () => {
|
it('leaves already-invited entries untouched', async () => {
|
||||||
const invited = await testUtils.createAllowlistEntry({ email: 'already@example.com', invited: true });
|
const invited = await testUtils.createAllowlistEntry({ email: 'already@example.com', invited: true });
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import type { EmailRepository } from '@common/server/email';
|
||||||
import type { LoggerRepository, WideContextRepository } from '@common/server/otel';
|
import type { LoggerRepository, WideContextRepository } from '@common/server/otel';
|
||||||
import type { DatabaseRepository } from 'src/repositories/database.repository';
|
import type { DatabaseRepository } from 'src/repositories/database.repository';
|
||||||
import type { OidcRepository } from 'src/repositories/oidc.repository';
|
import type { OidcRepository } from 'src/repositories/oidc.repository';
|
||||||
|
import type { UserAllowlistRepository } from 'src/repositories/userAllowlist.repository';
|
||||||
|
|
||||||
export type RepositoryInterface<T extends object> = Pick<T, keyof T>;
|
export type RepositoryInterface<T extends object> = Pick<T, keyof T>;
|
||||||
|
|
||||||
@@ -20,6 +22,24 @@ export const newOidcRepositoryMock = (): jest.Mocked<RepositoryInterface<OidcRep
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const newEmailRepositoryMock = (): jest.Mocked<RepositoryInterface<EmailRepository>> => {
|
||||||
|
return {
|
||||||
|
send: jest.fn(),
|
||||||
|
sendBatch: jest.fn(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const newUserAllowlistRepositoryMock = (): jest.Mocked<RepositoryInterface<UserAllowlistRepository>> => {
|
||||||
|
return {
|
||||||
|
list: jest.fn(),
|
||||||
|
getByEmail: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
deleteByEmail: jest.fn(),
|
||||||
|
oldestStaged: jest.fn(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const newLoggerRepositoryMock = (): jest.Mocked<RepositoryInterface<LoggerRepository>> => {
|
export const newLoggerRepositoryMock = (): jest.Mocked<RepositoryInterface<LoggerRepository>> => {
|
||||||
return {
|
return {
|
||||||
debug: jest.fn(),
|
debug: jest.fn(),
|
||||||
@@ -45,6 +65,8 @@ export const newMocks = () => {
|
|||||||
return {
|
return {
|
||||||
database: newDatabaseRepositoryMock(),
|
database: newDatabaseRepositoryMock(),
|
||||||
oidc: newOidcRepositoryMock(),
|
oidc: newOidcRepositoryMock(),
|
||||||
|
email: newEmailRepositoryMock(),
|
||||||
|
allowlist: newUserAllowlistRepositoryMock(),
|
||||||
logger: newLoggerRepositoryMock(),
|
logger: newLoggerRepositoryMock(),
|
||||||
wideContext: newWideContextRepositoryMock(),
|
wideContext: newWideContextRepositoryMock(),
|
||||||
metrics: newMetricServiceMock(),
|
metrics: newMetricServiceMock(),
|
||||||
|
|||||||
@@ -29,11 +29,13 @@ export const testUtils = {
|
|||||||
email,
|
email,
|
||||||
inviteCode,
|
inviteCode,
|
||||||
invited = false,
|
invited = false,
|
||||||
|
inviteEmailSentAt,
|
||||||
createdAt,
|
createdAt,
|
||||||
}: {
|
}: {
|
||||||
email: string;
|
email: string;
|
||||||
inviteCode?: string;
|
inviteCode?: string;
|
||||||
invited?: boolean;
|
invited?: boolean;
|
||||||
|
inviteEmailSentAt?: Date;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
}) => {
|
}) => {
|
||||||
return getDb()
|
return getDb()
|
||||||
@@ -42,6 +44,7 @@ export const testUtils = {
|
|||||||
email,
|
email,
|
||||||
inviteCode: inviteCode ?? randomUUID().slice(0, 10).toUpperCase(),
|
inviteCode: inviteCode ?? randomUUID().slice(0, 10).toUpperCase(),
|
||||||
invited,
|
invited,
|
||||||
|
...(inviteEmailSentAt ? { inviteEmailSentAt } : {}),
|
||||||
...(createdAt ? { createdAt } : {}),
|
...(createdAt ? { createdAt } : {}),
|
||||||
})
|
})
|
||||||
.returningAll()
|
.returningAll()
|
||||||
|
|||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "userAllowlist" ADD "inviteEmailSentAt" timestamp with time zone;`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "userAllowlist" DROP COLUMN "inviteEmailSentAt";`.execute(db);
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ export class UserAllowlistTable {
|
|||||||
@Column({ type: 'timestamp with time zone', nullable: true })
|
@Column({ type: 'timestamp with time zone', nullable: true })
|
||||||
inviteUsedAt!: Date | null;
|
inviteUsedAt!: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp with time zone', nullable: true })
|
||||||
|
inviteEmailSentAt!: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp with time zone', default: () => 'now()' })
|
@Column({ type: 'timestamp with time zone', default: () => 'now()' })
|
||||||
createdAt!: Generated<Date>;
|
createdAt!: Generated<Date>;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3
@@ -837,6 +837,9 @@ importers:
|
|||||||
|
|
||||||
packages/yucca-admin-api:
|
packages/yucca-admin-api:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@common/emails':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../emails
|
||||||
'@common/server':
|
'@common/server':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../common
|
version: link:../common
|
||||||
|
|||||||
Reference in New Issue
Block a user