@quatronic/sdk 0.6.1-rc.4 → 0.7.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +26 -0
  2. package/dist/cli/legacy/qdc-sourcemaps.js +237 -0
  3. package/dist/cli/legacy/qdc-test-report.js +200 -0
  4. package/dist/cli/skills-postinstall.js +603 -0
  5. package/dist/cli/skills.js +499 -0
  6. package/dist/cli/sourcemaps.js +9 -9
  7. package/dist/cli/test-report.js +2 -2
  8. package/dist/next/config/index.js +1 -1
  9. package/dist/next/config/index.js.map +1 -1
  10. package/package.json +10 -4
  11. package/postinstall.mjs +9 -0
  12. package/skills/.gitattributes +1 -0
  13. package/skills/catalog.json +24 -0
  14. package/skills/qore-add-api-route/SKILL.md +58 -0
  15. package/skills/qore-add-connection/SKILL.next.md +149 -0
  16. package/skills/qore-add-connection/SKILL.node.md +160 -0
  17. package/skills/qore-add-db-table/SKILL.md +88 -0
  18. package/skills/qore-add-db-table/references/cross-module-reference.md +74 -0
  19. package/skills/qore-add-feature/SKILL.md +77 -0
  20. package/skills/qore-add-integration/SKILL.next.md +217 -0
  21. package/skills/qore-add-integration/SKILL.node.md +216 -0
  22. package/skills/qore-add-page/SKILL.md +53 -0
  23. package/skills/qore-author-skill/SKILL.md +136 -0
  24. package/skills/qore-debug-issue/SKILL.md +48 -0
  25. package/skills/qore-enable-auth-method/SKILL.md +90 -0
  26. package/skills/qore-observability/SKILL.md +89 -0
  27. package/skills/qore-observability/references/errors.md +87 -0
  28. package/skills/qore-observability/references/logs.md +69 -0
  29. package/skills/qore-observability/references/tracing.md +58 -0
  30. package/skills/qore-recommended-tools/SKILL.md +58 -0
  31. package/skills/qore-secure-io/SKILL.md +174 -0
  32. package/skills/qore-ship-checklist/SKILL.md +80 -0
  33. package/skills/qore-translate-strings/SKILL.md +36 -0
  34. package/skills/qore-use-addons/SKILL.md +175 -0
  35. package/skills/qore-use-database/SKILL.md +222 -0
  36. package/skills/qore-use-design-system/SKILL.md +76 -0
  37. package/skills/qore-use-mail-domain/SKILL.next.md +67 -0
  38. package/skills/qore-use-mail-domain/SKILL.node.md +62 -0
  39. package/skills/qore-use-permissions/SKILL.md +116 -0
  40. package/skills/qore-verify-user/SKILL.md +56 -0
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: qore-ship-checklist
3
+ description: The quality gate to run before opening a pull request — translate the requirement into acceptance criteria, cover it with tests, get all automated checks green, and run an independent review. Use before committing a finished change or opening a PR.
4
+ allowed-tools: Bash(pnpm:*), Bash(git add:*), Bash(git commit:*), Read, Edit
5
+ ---
6
+
7
+ # Ship checklist
8
+
9
+ Run this before opening a PR. It encodes Quatronic's definition of done.
10
+
11
+ ## 1. Acceptance criteria
12
+
13
+ Restate the requirement as concrete, checkable acceptance criteria — what must
14
+ be true for this to be "done". Keep them in the PR description.
15
+
16
+ ## 2. Tests for those criteria
17
+
18
+ - **Unit** (`*.test.ts`) for pure logic and schemas — see /qore-add-feature.
19
+ {{#if db}}- **Integration** (`*.integration.test.ts`) for anything whose behaviour lives
20
+ in the database (constraints, upserts, joins).{{#if auth}} Mirror
21
+ `src/features/profile/server/queries.integration.test.ts`; run with
22
+ `pnpm db:up` then `pnpm test:integration`.
23
+ {{else}} Run with
24
+ `pnpm db:up` then `pnpm test:integration`.
25
+ {{/if}}{{else}}- (No database module: integration tests apply once the app gains stateful behaviour.)
26
+ {{/if}}- Write the test first where you can; watch it fail, then make it pass.
27
+
28
+ ## 3. All automated checks green
29
+
30
+ ```
31
+ {{#if db}}pnpm db:generate && {{/if}}pnpm lint && pnpm format:check && pnpm typecheck && pnpm test:run && pnpm build
32
+ ```
33
+
34
+ These are exactly what CI runs — `.github/workflows/ci.yml` calls Quatronic's
35
+ shared pipeline, which invokes these same scripts. If any fail locally, the
36
+ PR will be red — fix before pushing.
37
+
38
+ {{#if db}}CI runs `pnpm db:generate` as a drift check and fails when it writes
39
+ anything, so commit every migration it produces under `src/db/migrations`.
40
+
41
+ {{/if}}Also check by hand (the automated gates don't catch everything): no
42
+ third-party UI library imports, no `style={{ ... }}`, no raw/arbitrary color classes
43
+ (`bg-blue-500`, `text-[#1234ff]`) — use semantic tokens
44
+ (`text-destructive`, `bg-muted`, `border-border`) from `src/styles/theme.css`
45
+ instead.
46
+
47
+ If the change calls an external API, check after it deploys that the portal's
48
+ Integrations tab lists every endpoint you call. One missing means its alias or
49
+ path is not written as a literal at the call site — see
50
+ /qore-add-integration step 4.
51
+
52
+ ## 4. Independent review
53
+
54
+ - Run `/code-review` and resolve real findings.
55
+ - {{#if auth}}Run `/security-review` — this app handles Keycloak sessions and per-user
56
+ data, so treat anything touching auth, roles, or user-scoped tables as
57
+ high-value.{{else}}Run `/security-review` — treat anything touching external input or
58
+ stored data as high-value.{{/if}} Resolve all critical/high findings before merge.
59
+ {{#if auth=keycloak}}- Added or renamed a role? Say in the PR who should get it on staging and
60
+ production — nobody holds a new role there until someone grants it in the
61
+ portal's Access tab.
62
+ {{/if}}
63
+
64
+ ## 5. Observability
65
+
66
+ Walk the new code against `/qore-observability`:
67
+
68
+ - Unexpected failures are left to throw (captured automatically, so no extra capture), no
69
+ error is swallowed without `Sentry.captureException`, and no capture of expected failures
70
+ (validation, not found, denied).
71
+ - Business and audit events logged once with `Sentry.logger`; audit events carry
72
+ `qore.audit.event`{{#if auth}} (login/logout, authorization denials, mutations){{/if}}.
73
+ - Custom attributes are `qore.*` and hold ids, never personal data or secrets.
74
+ - No `console` in runtime code. `pnpm lint` is green.
75
+
76
+ ## 6. Commit & PR
77
+
78
+ Small, focused commits on a feature branch. Open a PR (never push to
79
+ `main`/`production` directly) so the change is reviewable, traceable, and
80
+ revertable.
@@ -0,0 +1,36 @@
1
+ ---
2
+ name: qore-translate-strings
3
+ description: Add or change user-facing strings the bilingual (NL/EN) way — message keys in both catalogs, resolved with next-intl. Use whenever you write or edit visible copy.
4
+ allowed-tools: Bash(pnpm:*), Edit, Read
5
+ ---
6
+
7
+ # Translate strings
8
+
9
+ This app is bilingual (Dutch + English) out of the box. Default locale is `nl`;
10
+ the locale lives in a `locale` cookie (no `[locale]` in the URL). Never hardcode
11
+ user-facing copy — every visible string is a key in `messages/nl.json` and
12
+ `messages/en.json`.
13
+
14
+ ## Adding or changing a string
15
+
16
+ 1. Pick a namespace + key (e.g. `profile.displayName`). Reuse generic keys in
17
+ `common` (`save`, `saved`, `cancel`) instead of duplicating.
18
+ 2. Add the SAME key to BOTH `messages/nl.json` and `messages/en.json`. The
19
+ vitest parity test fails if the key sets differ.
20
+ 3. Use it:
21
+ - **Client or server component:** `const t = useTranslations("profile")` then
22
+ `t("displayName")`. (`useTranslations` works in both in next-intl v4.)
23
+ - **Dynamic / full-path key** (e.g. a value coming from a server action):
24
+ `const tRoot = useTranslations()` then `tRoot(fullKey)`.
25
+ - **Metadata / outside the render tree:** `getTranslations` from
26
+ `next-intl/server`.
27
+ 4. **Zod validation messages:** store the full message key as the zod message
28
+ (`.min(1, "profile.displayNameRequired")`) and resolve it where the form
29
+ shows `state.message` with a root translator. Mirror `src/features/profile/`.
30
+
31
+ ## Verify
32
+
33
+ - `pnpm test:run` — runs the parity test (`src/i18n/messages.test.ts`).
34
+ - `pnpm typecheck` — typed messages (`global.d.ts`) catch mistyped keys.
35
+
36
+ Both run as part of /qore-ship-checklist.
@@ -0,0 +1,175 @@
1
+ ---
2
+ name: qore-use-addons
3
+ description: Read the credentials of an addon (Postgres, Redis, object storage) that THIS module provisions in the portal. Use when your code needs to talk to a database, cache, or S3 bucket that you added to this module — covers the exact env-var names and how to read them.
4
+ allowed-tools: Bash(pnpm:*), Edit, Read
5
+ ---
6
+
7
+ # Use an addon
8
+
9
+ An **addon** is infrastructure the portal provisions for *this* module — a
10
+ Postgres database, a Redis cache, or an S3-compatible object store. You add it
11
+ in the portal (the module's Resources tab); the portal then binds its
12
+ credentials into the module's secret group for every environment, so the values
13
+ land in `process.env` at runtime. You never copy a secret by hand.
14
+ Adding or removing a resource also restarts the module automatically, so the
15
+ env vars are live right away.
16
+
17
+ The credentials arrive under **stable, addon-independent env-var names** (the
18
+ portal aliases Northflank's raw keys to these). Read these names — not the
19
+ `NF_*` keys you see in the Northflank dashboard:
20
+
21
+ | Addon | Env var(s) your code reads |
22
+ |-------|----------------------------|
23
+ | **Postgres** | `DATABASE_URL` |
24
+ | **Redis** | `REDIS_URL` |
25
+ | **Object storage** (S3 / MinIO) | `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
26
+
27
+ > These keys only exist once you've added the addon to this module in the
28
+ > portal. Until then they're absent — that's why every read below fails fast
29
+ > with a clear message instead of a mystery `undefined`.
30
+ >
31
+ > Need credentials for an addon owned by **another** module? That's a
32
+ > connection, not an addon — use `/qore-add-connection` (the keys are
33
+ > `CONN_<ALIAS>_*` and you read them via the SDK), not this skill.
34
+
35
+ ## 1. Declare the env keys
36
+
37
+ Add the keys for the addons you use to the server schema in
38
+ `src/config/env.ts`, so a missing binding is a clear startup error:
39
+
40
+ ```ts
41
+ // inside serverSchema = z.object({ ... })
42
+ DATABASE_URL: z.string().min(1), // Postgres
43
+ REDIS_URL: z.string().url(), // Redis
44
+ S3_ENDPOINT: z.string().url(), // Object storage
45
+ S3_ACCESS_KEY_ID: z.string().min(1),
46
+ S3_SECRET_ACCESS_KEY: z.string().min(1),
47
+ ```
48
+
49
+ `DATABASE_URL` is already declared when this app was generated with a database.
50
+ Add the others only for the addons you actually provisioned.
51
+
52
+ ## 2. Postgres — use the existing client
53
+
54
+ {{#if db}}The database client already reads `DATABASE_URL` for you. Don't build a new
55
+ pool; use the shared one:
56
+
57
+ ```ts
58
+ import { getDb } from "@/lib/db"
59
+
60
+ const db = getDb() // Drizzle over a lazily-created pg.Pool on DATABASE_URL
61
+ ```
62
+
63
+ To add a table, use `/qore-add-db-table`.
64
+ {{else}}(This app has no Postgres module — if you add the Postgres addon here, read it
65
+ via `DATABASE_URL` with your own client.)
66
+ {{/if}}
67
+
68
+ ## 3. Redis
69
+
70
+ Install a client (`pnpm add ioredis`), then connect lazily — never at module
71
+ scope, or `next build` (which imports modules without runtime env) breaks:
72
+
73
+ ```ts
74
+ // src/lib/redis.ts
75
+ import Redis from "ioredis"
76
+ import { serverEnv } from "@/config/env"
77
+
78
+ let _redis: Redis | undefined
79
+
80
+ export function getRedis(): Redis {
81
+ if (!_redis) _redis = new Redis(serverEnv().REDIS_URL)
82
+ return _redis
83
+ }
84
+ ```
85
+
86
+ **Before you can run this locally**, `REDIS_URL` doesn't exist yet — the
87
+ generator can't scaffold it into `.env.example` because the addon is added
88
+ later, in the portal. Do this now, not as an afterthought:
89
+
90
+ - Run a Redis yourself (e.g. `docker run -p 6379:6379 redis`), then add
91
+ `REDIS_URL=redis://localhost:6379` to `.env.local`.
92
+ - Or, if you won't run one locally, declare the key as `.optional()` in
93
+ `env.ts` and guard the code path instead — the schema is all-or-nothing, so
94
+ a *required* key that's absent locally crashes every `serverEnv()` call,
95
+ not just the addon feature.
96
+
97
+ ## 4. Object storage (S3 / MinIO)
98
+
99
+ The store is S3-compatible, so use the AWS SDK pointed at `S3_ENDPOINT`
100
+ (`pnpm add @aws-sdk/client-s3`). MinIO needs `forcePathStyle: true`:
101
+
102
+ ```ts
103
+ // src/lib/storage.ts
104
+ import { CreateBucketCommand, S3Client } from "@aws-sdk/client-s3"
105
+ import { serverEnv } from "@/config/env"
106
+
107
+ // The MinIO addon is exclusive to this module+environment and ships WITHOUT a
108
+ // bucket, so we create one on first use under a fixed name. No S3_BUCKET env
109
+ // var exists or is needed.
110
+ export const BUCKET = "app"
111
+
112
+ let _s3: S3Client | undefined
113
+ let _ensured: Promise<void> | undefined
114
+
115
+ export function getS3(): S3Client {
116
+ if (!_s3) {
117
+ const env = serverEnv()
118
+ _s3 = new S3Client({
119
+ endpoint: env.S3_ENDPOINT,
120
+ region: "us-east-1", // ignored by MinIO, required by the SDK
121
+ forcePathStyle: true,
122
+ credentials: {
123
+ accessKeyId: env.S3_ACCESS_KEY_ID,
124
+ secretAccessKey: env.S3_SECRET_ACCESS_KEY,
125
+ },
126
+ })
127
+ }
128
+ return _s3
129
+ }
130
+
131
+ export function ensureBucket(): Promise<void> {
132
+ if (!_ensured) {
133
+ _ensured = getS3()
134
+ .send(new CreateBucketCommand({ Bucket: BUCKET }))
135
+ .then(() => undefined)
136
+ .catch((err: unknown) => {
137
+ const name = (err as { name?: string })?.name
138
+ if (name === "BucketAlreadyOwnedByYou" || name === "BucketAlreadyExists") return
139
+ _ensured = undefined // allow retry on a real failure
140
+ throw err
141
+ })
142
+ }
143
+ return _ensured
144
+ }
145
+ ```
146
+
147
+ Await `ensureBucket()` before the first storage operation, then use the fixed
148
+ bucket:
149
+
150
+ ```ts
151
+ import { PutObjectCommand } from "@aws-sdk/client-s3"
152
+ import { BUCKET, ensureBucket, getS3 } from "@/lib/storage"
153
+
154
+ await ensureBucket()
155
+ await getS3().send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: body }))
156
+ ```
157
+
158
+ **Before you can run this locally**, same story as Redis — `S3_ENDPOINT` /
159
+ `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` aren't in `.env.example`. Run a
160
+ MinIO yourself (e.g. `docker run -p 9000:9000 quay.io/minio/minio server /data`) and
161
+ add the three keys to `.env.local`, or declare them `.optional()` and guard
162
+ the code path if you won't run one locally.
163
+
164
+ ## Local development recap
165
+
166
+ {{#if db}}- **Postgres** — `pnpm dev` uses the Postgres from `docker compose up -d`;
167
+ `DATABASE_URL` is already in `.env.example`.
168
+ {{/if}}- **Redis / object storage** — see the "before you can run this locally" note
169
+ in steps 3/4 above — set the matching keys in `.env.local`, they are not
170
+ scaffolded for you.
171
+
172
+ ## Ship it
173
+
174
+ Run the full local check loop — `pnpm lint && pnpm typecheck && pnpm test:run &&
175
+ pnpm build` — then `/qore-ship-checklist` before a PR.
@@ -0,0 +1,222 @@
1
+ ---
2
+ name: qore-use-database
3
+ description: How this module's Postgres database works — where DATABASE_URL comes from, running the database locally, adding a client (pg or Drizzle), and applying migrations. Use when reading or writing data, adding a table, or debugging a connection error.
4
+ allowed-tools: Bash(pnpm:*), Bash(docker:*), Read, Edit, Write
5
+ ---
6
+
7
+ # Using this module's database
8
+
9
+ This module owns a Postgres database. The platform provisions it and injects the
10
+ connection string; the module brings its own client. Nothing is pre-wired — this
11
+ skill is the whole contract.
12
+
13
+ ## 1. Where `DATABASE_URL` comes from
14
+
15
+ `src/config/env.ts` requires `DATABASE_URL` (`z.string().min(1)`), so the service
16
+ refuses to boot without it.
17
+
18
+ | Where | How it arrives |
19
+ |-------|----------------|
20
+ | **Local** | `pnpm bootstrap` (or `qore env pull`) fetches it from the portal into `.env`. It points at the docker-compose database below on this module's own port: `postgresql://postgres:postgres@localhost:<DB_PORT>/app`, with `DB_PORT` written alongside it |
21
+ | **staging / prod** | The portal injects the Northflank Postgres addon's own connection string per environment. Different host, database and credentials than local — never assume the local values. |
22
+
23
+ Rules: never hardcode a connection string, never commit `.env` (it is gitignored),
24
+ never read `process.env.DATABASE_URL` directly — import `env` from
25
+ `./config/env.js` so a missing value fails fast at boot instead of at first query.
26
+
27
+ If `pnpm dev` dies on `DATABASE_URL: Required`, your `.env` is stale: re-run
28
+ `qore env pull` (or `qore run -- pnpm dev` to skip the file).
29
+
30
+ ## 2. Running the database locally
31
+
32
+ ```
33
+ pnpm db:up # start Postgres (docker compose up -d)
34
+ pnpm db:down # stop it, keep the data
35
+ pnpm db:reset # destroy the volume and start clean
36
+ ```
37
+
38
+ `docker-compose.yml` creates database `app` with user/password `postgres`.
39
+ `pnpm bootstrap` already runs `db:up`. The host port is `DB_PORT` from `.env`:
40
+ the portal assigns each module its own, so two modules with a database never
41
+ fight over a port. `ECONNREFUSED 127.0.0.1:<DB_PORT>` usually
42
+ means the container is not running — `pnpm db:up` — but `db:up` is `docker
43
+ compose up -d` with no health wait, so the same error is also normal for a
44
+ second or two right after starting; check `docker compose ps` / the compose
45
+ healthcheck, or just retry.
46
+
47
+ Don't change `DB_PORT` or the port in `DATABASE_URL` by hand: both are
48
+ portal-managed, and `qore env pull` overwrites them with the assigned values.
49
+ If the port is taken anyway, something else is listening on it — stop that, or
50
+ ask for the module's local ports to be reassigned in the portal.
51
+
52
+ A psql shell into the running container:
53
+
54
+ ```
55
+ docker compose exec db psql -U postgres -d app
56
+ ```
57
+
58
+ ## 3. Adding a client
59
+
60
+ Pick one and stay with it. Both are traced automatically: the SDK's Sentry
61
+ register instruments `pg`, so every query becomes a span — Drizzle included.
62
+
63
+ ### Option A — `pg` (default: no ORM, plain SQL)
64
+
65
+ ```
66
+ pnpm add pg && pnpm add -D @types/pg
67
+ ```
68
+
69
+ Create `src/lib/db.ts` — one pool per process, created at module load;
70
+ connections are opened on demand as queries need them. Never open a client
71
+ per request:
72
+
73
+ ```ts
74
+ import { Pool, type QueryResultRow } from "pg"
75
+ import { resolvePostgresConfig } from "@quatronic/sdk/node/db"
76
+ import { env } from "../config/env.js"
77
+
78
+ // One pool per process. Never configure `ssl` by hand here — always spread
79
+ // resolvePostgresConfig(): it owns the TLS decision (no TLS for local
80
+ // docker-compose, encrypted-but-unverified for Northflank's managed Postgres,
81
+ // which terminates TLS with its own private CA, and full certificate
82
+ // verification for any external database) and it strips the ssl params out of
83
+ // the URL, without which `pg` silently overrides whatever `ssl` you pass —
84
+ // it parses the connection string after the client options.
85
+ const pool = new Pool({ ...resolvePostgresConfig(env.DATABASE_URL), max: 10 })
86
+
87
+ export function query<T extends QueryResultRow>(
88
+ sql: string,
89
+ params: unknown[] = [],
90
+ ): Promise<T[]> {
91
+ return pool.query<T>(sql, params).then((r) => r.rows)
92
+ }
93
+
94
+ /** Run several statements on one connection inside a transaction. */
95
+ export async function transaction<T>(fn: (c: import("pg").PoolClient) => Promise<T>): Promise<T> {
96
+ const client = await pool.connect()
97
+ try {
98
+ await client.query("BEGIN")
99
+ const out = await fn(client)
100
+ await client.query("COMMIT")
101
+ return out
102
+ } catch (err) {
103
+ await client.query("ROLLBACK")
104
+ throw err
105
+ } finally {
106
+ client.release()
107
+ }
108
+ }
109
+
110
+ export function closePool(): Promise<void> {
111
+ return pool.end()
112
+ }
113
+ ```
114
+
115
+ Always parameterise — `query("select * from widgets where id = $1", [id])`.
116
+ String-interpolating a value into SQL is an injection bug; there are no
117
+ exceptions to this.
118
+
119
+ Close the pool on shutdown, in `src/index.ts`:
120
+
121
+ ```ts
122
+ import { closePool } from "./lib/db.js"
123
+
124
+ process.on("SIGTERM", () => {
125
+ server.close(() => {
126
+ void closePool().finally(() => process.exit(0))
127
+ })
128
+ })
129
+ ```
130
+
131
+ ### Option B — Drizzle (typed schema + generated migrations)
132
+
133
+ ```
134
+ pnpm add drizzle-orm pg && pnpm add -D drizzle-kit @types/pg tsx
135
+ ```
136
+
137
+ Then follow the frontend convention: `src/db/schema.ts` defines tables on
138
+ `pgSchema("app")`, `drizzle.config.ts` points `out` at `src/db/migrations`, and
139
+ `drizzle-kit generate` writes the SQL. Commit the generated SQL and `meta/` and
140
+ never hand-edit them — change `schema.ts` and regenerate.
141
+
142
+ Drizzle runs on a `pg` pool, so build it exactly like option A —
143
+ `new Pool({ ...resolvePostgresConfig(env.DATABASE_URL), max: 10 })` — and pass
144
+ that pool to `drizzle()`. The same rule applies: never set `ssl` yourself.
145
+
146
+ If the service dies at boot with `Error: self-signed certificate`, this is what
147
+ was skipped: something is verifying Northflank's private CA, which can never
148
+ succeed.
149
+
150
+ ## 4. Migrations
151
+
152
+ There is no migration runner in this module; that is deliberate — you choose the
153
+ tool. Whatever you pick, the rules are the same:
154
+
155
+ - Migrations are **files in the repo**, applied automatically at boot. Do not
156
+ create tables by hand in a psql shell: staging and prod have databases you
157
+ cannot reach.
158
+ - Forward-only. Never edit a migration that has already run in an environment —
159
+ add a new one.
160
+
161
+ Minimal plain-SQL runner if you took option A. Add `src/db/migrations/0001_init.sql`,
162
+ then `src/lib/migrate.ts`:
163
+
164
+ ```ts
165
+ import { readdir, readFile } from "node:fs/promises"
166
+ import { join } from "node:path"
167
+ import * as Sentry from "@sentry/node"
168
+ import { transaction } from "./db.js"
169
+
170
+ // dist/lib/migrate.js runs from dist/, and tsc never copies .sql files — the
171
+ // build script must copy src/db/migrations into dist/db/migrations, e.g.:
172
+ // "build": "tsc -p tsconfig.json && cp -R src/db/migrations dist/db/migrations"
173
+ // Without that step this throws ENOENT, in local dev (`pnpm dev`/`pnpm start`
174
+ // run dist/) and in the container alike (the Dockerfile copies dist/ wholesale).
175
+ const MIGRATIONS_DIR = join(import.meta.dirname, "../db/migrations")
176
+
177
+ export async function runMigrations(): Promise<void> {
178
+ const files = (await readdir(MIGRATIONS_DIR)).filter((f) => f.endsWith(".sql")).sort()
179
+ await transaction(async (client) => {
180
+ // Serialise concurrent boots (two replicas start at once) on one advisory
181
+ // lock, taken BEFORE the table it protects — otherwise two replicas can
182
+ // still race the create-table statement itself.
183
+ await client.query("select pg_advisory_xact_lock(hashtext('schema_migrations'))")
184
+ await client.query(
185
+ "create table if not exists schema_migrations (name text primary key, applied_at timestamptz not null default now())",
186
+ )
187
+ const { rows } = await client.query<{ name: string }>("select name from schema_migrations")
188
+ const applied = new Set(rows.map((r) => r.name))
189
+ for (const name of files.filter((f) => !applied.has(f))) {
190
+ await client.query(await readFile(join(MIGRATIONS_DIR, name), "utf8"))
191
+ await client.query("insert into schema_migrations (name) values ($1)", [name])
192
+ Sentry.logger.info("migration applied", { "qore.migration.name": name })
193
+ }
194
+ })
195
+ }
196
+ ```
197
+
198
+ This runner wraps *all* pending migrations in a single transaction, so a
199
+ statement that cannot run inside a transaction block (e.g. `CREATE INDEX
200
+ CONCURRENTLY`, or `ALTER TYPE … ADD VALUE` on older Postgres versions) will
201
+ fail. Such a migration needs to run outside the shared transaction — give it
202
+ its own connection/transaction rather than adding it to the loop above.
203
+
204
+ Call it in `src/index.ts` **before** `server.listen` and let a failure crash the
205
+ process — a service running against an unmigrated database is worse than one that
206
+ does not start:
207
+
208
+ ```ts
209
+ await runMigrations()
210
+ server.listen(env.PORT, () => { /* ... */ })
211
+ ```
212
+
213
+ ## 5. Sharing this database with another module
214
+
215
+ Do not hand your `DATABASE_URL` to another module. The portal models this as a
216
+ connection: the consumer gets `CONN_<ALIAS>_URI` / `_HOST` / `_USER` / … for your
217
+ resource. See the `qore-add-connection` skill.
218
+
219
+ ## 6. Before you ship
220
+
221
+ `pnpm lint && pnpm typecheck && pnpm test:run && pnpm build`, and check that a fresh clone works:
222
+ `pnpm db:reset && pnpm bootstrap && pnpm build && pnpm dev`.
@@ -0,0 +1,76 @@
1
+ ---
2
+ name: qore-use-design-system
3
+ description: Use this app's house component kit and design tokens. Use whenever building or restyling UI — pages, forms, tables, dialogs — so the app stays visually consistent.
4
+ ---
5
+
6
+ # Use the design system
7
+
8
+ This app ships a house component kit in `src/components/ui/` (built on shadcn/ui
9
+ + Radix primitives) and a token canon in `src/styles/theme.css`. Build UI on
10
+ top of it instead of reaching for raw colors or one-off markup.
11
+
12
+ ## Rules
13
+
14
+ 1. **Import UI from `@/components/ui`** — the barrel — never a deep path like
15
+ `@/components/ui/Button`. (`eslint-plugin-no-restricted-imports` enforces
16
+ this: only the kit's own files are exempt.) Never import a UI library
17
+ directly in pages/features either.
18
+ 2. **Use canon tokens only.** The semantic set from `theme.css`: `background`/
19
+ `foreground`, `card`(+`-foreground`), `popover`(+`-foreground`), `primary`
20
+ (+`-foreground`), `secondary`(+`-foreground`), `muted`(+`-foreground`),
21
+ `accent`(+`-foreground`), `destructive`(+`-foreground`), `border`, `input`,
22
+ `ring`, `radius` — e.g. `bg-card`, `text-muted-foreground`,
23
+ `border-border`. Never raw palette colors (`text-red-500`, `bg-[#...]`) and
24
+ never the `style={{}}` prop — both are ESLint errors. Layout/spacing
25
+ utilities (`flex`, `gap-4`, `p-6`, …) are fine everywhere.
26
+ 3. **Reach for layout primitives first** — `PageContainer`, `PageHeader`,
27
+ `PageHero`, `EmptyState`, `ErrorState`, `LoadingState`, `SectionCard` —
28
+ before composing something new.
29
+ 4. **Re-theme by editing `src/styles/theme.css`** — the single swap-point
30
+ (`:root` + `.dark`) — not by touching component internals or sprinkling
31
+ colors into features.
32
+ 5. **Feature-specific tokens** (status colors, severity, etc.) go in a
33
+ co-located `tokens.ts`, imported — never duplicated inline.
34
+
35
+ ## Missing a component?
36
+
37
+ If the kit lacks a wrapper you need, add it to `src/components/ui/` in the
38
+ same thin-wrapper style (with a co-located `*.test.tsx`) and export it from
39
+ `index.ts` — don't drop a raw UI library import into a feature. The kit is
40
+ yours to grow; keep features importing only from the barrel.
41
+
42
+ ## React-Aria-style compat props
43
+
44
+ Several components preserve React-Aria-style prop names on top of their
45
+ native/Radix API for continuity: `onPress`, `isDisabled`, `isLoading`,
46
+ `isInvalid`, `isRequired`, `onSelectionChange`, `isOpen`/`onOpenChange`. Prefer
47
+ these where a component supports them instead of mixing in native DOM handler
48
+ names.
49
+
50
+ Note the split on overlays: `Modal` is controlled via `isOpen`/`onOpenChange`,
51
+ while `Sheet` uses Radix's native `open`/`onOpenChange` — don't mix them up.
52
+
53
+ ## Available components
54
+
55
+ Form/controls: `Button` · `Field`/`Input`/`Textarea` · `Select` · `Checkbox`/
56
+ `CheckboxGroup` · `SearchInput` · `FilterPills`.
57
+
58
+ Data display: `Badge` · `StatCard` · `DataTable`/`DataRow` · `Table` · `Tabs` ·
59
+ `Card`/`CardHeader`/`CardFooter`/`CardTitle`/`CardAction`/`CardDescription`/
60
+ `CardContent` · `Avatar`/`AvatarImage`/`AvatarFallback` · `Skeleton` ·
61
+ `Spinner` · `Link`.
62
+
63
+ Overlays: `Modal` · `Sheet` (+ `SheetTrigger`/`SheetClose`/`SheetContent`/
64
+ `SheetHeader`/`SheetFooter`/`SheetTitle`/`SheetDescription`) · `DropdownMenu`
65
+ (+ `DropdownMenuTrigger`/`Content`/`Group`/`Label`/`Item`/`CheckboxItem`/
66
+ `RadioGroup`/`RadioItem`/`Separator`/`Shortcut`/`Sub`/`SubTrigger`/
67
+ `SubContent`/`Portal`) · `Popover` (+ `PopoverAnchor`/`PopoverContent`/
68
+ `PopoverDescription`/`PopoverHeader`/`PopoverTitle`/`PopoverTrigger`) ·
69
+ `Tooltip` (+ `TooltipProvider`/`TooltipRoot`/`TooltipTrigger`/
70
+ `TooltipContent`).
71
+
72
+ Layout: `PageContainer` · `PageHeader` · `PageHero` · `SectionCard` ·
73
+ `EmptyState` · `ErrorState` · `LoadingState`.
74
+
75
+ All exported from `src/components/ui/index.ts` — check there for the
76
+ authoritative, up-to-date list.
@@ -0,0 +1,67 @@
1
+ ---
2
+ name: qore-use-mail-domain
3
+ description: Send email through a mail domain linked to this module in the portal, using MAIL_API_KEY/MAIL_SENDING_DOMAIN/MAIL_FROM. Use when this app needs to send its own business email (notifications, receipts, alerts) — not auth email, which next-betterauth already handles separately.
4
+ allowed-tools: Bash(pnpm:*), Edit, Read
5
+ ---
6
+
7
+ # Use a linked mail domain
8
+
9
+ A **mail domain** is a sending domain the tenant links to *this* module in the
10
+ portal (Module → Mail tab — pick a sending domain, a local part, and a sender
11
+ name). Linking pushes three env vars into this module's `stag`/`prod`
12
+ environments:
13
+
14
+ | Env var | What it is |
15
+ |---------|------------|
16
+ | `MAIL_API_KEY` | a domain-scoped token for sending mail |
17
+ | `MAIL_SENDING_DOMAIN` | the domain, e.g. `acme.nl` — informational only, already baked into `MAIL_FROM` |
18
+ | `MAIL_FROM` | the full from-header, e.g. `Acme Support <support@acme.nl>` |
19
+
20
+ > These are system-managed: they never appear in `.env.example`, never show up
21
+ > in this module's Secrets tab, and you cannot set them yourself. Until a
22
+ > domain is linked they're simply absent.
23
+
24
+ ## Hard rule: never present in `local` or preview
25
+
26
+ Unlike an addon (Redis, S3 — see `/qore-use-addons`) there is no way to
27
+ stand up a real sending domain on your laptop or in a preview deploy.
28
+ `MAIL_*` is **pushed into every deployed environment (today: `stag` and
29
+ `prod`) — never `local`, never preview**. Don't go looking for a way to test
30
+ a real send outside deployed environments — `sendMail` already handles this
31
+ by falling back to a warning log, and that fallback is correct behaviour,
32
+ not a placeholder to replace later.
33
+
34
+ ## Send mail
35
+
36
+ Use `sendMail` from `src/lib/mail.ts`:
37
+
38
+ ```ts
39
+ import { sendMail } from "@/lib/mail"
40
+
41
+ await sendMail({
42
+ to: user.email,
43
+ subject: "Your export is ready",
44
+ html: "<p>...</p>",
45
+ text: "...",
46
+ })
47
+ ```
48
+
49
+ It reads `MAIL_API_KEY`/`MAIL_FROM` from `process.env` and calls Resend's send
50
+ API. When `MAIL_API_KEY` is absent it logs `mail skipped` via
51
+ `Sentry.logger.warn` (no recipient, subject or body) and returns, so
52
+ local/preview dev works without a real domain.
53
+
54
+ Worked example: `docs/examples/mail.example.ts`.
55
+
56
+ ## This is not auth email
57
+
58
+ If this app has Better Auth (`next-betterauth`), verification and magic-link
59
+ email already flow through `src/lib/email/send.ts` and its own
60
+ `RESEND_API_KEY`/`EMAIL_FROM` — a separate mechanism tied to the portal's
61
+ platform sender, not to a per-module linked domain. Don't merge the two;
62
+ `sendMail` here is for this module's own business email.
63
+
64
+ ## Ship it
65
+
66
+ Run the full local check loop — `pnpm lint && pnpm typecheck && pnpm test:run
67
+ && pnpm build` — then `/qore-ship-checklist` before a PR.