commit f23f4e535d6a194d866a802e92292109a8635a31 Author: ConusAI vendor v0.1.0-dev.20261027+9b1131fb Date: Tue Oct 27 18:05:18 2026 +0000 ConusAI vendor v0.1.0-dev.20261027+9b1131fb diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e7903e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.next/ +.env* +!.env.example +next-env.d.ts +tsconfig.tsbuildinfo +playwright-report/ +test-results/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/README.md b/README.md new file mode 100644 index 0000000..e2732df --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# ConusAI Showcase + +Notes with attachments on Postgres and Blob Storage, audited, observed and +metered: every ConusAI SDK in one Next.js app built with Feature-Sliced Design +(showcase plan D11). The template is proven in this repository by +`scripts/vendor/test-template.sh` at the same commit as the SDKs it uses (D18). + +## Pinned versions + +Pinned on 2026-10-06 with `npm view version` (showcase §2.7): + +| Package | Version | +|---|---| +| next | 16.3.8 | +| react, react-dom | 19.3.0 | +| typescript | 5.9.3 (5.x: the `@conusai-sdk/plugin` peer requires ^5) | +| drizzle-orm, drizzle-kit | 0.45.3, 0.31.11 | +| postgres | 3.4.9 | +| zod | 4.6.5 | +| server-only | 0.0.1 | +| vitest | 5.0.3 | +| eslint, eslint-config-next, @eslint/eslintrc | 9.39.5 (ESLint 10 breaks the React plugin bundled by eslint-config-next), 16.3.8, 3.3.7 | +| steiger, @feature-sliced/steiger-plugin | 0.7.0, 0.8.0 | +| @playwright/test | 1.63.0 | +| playwright-core | 1.63.0 (`@axe-core/playwright`'s peer; must equal `@playwright/test`'s copy, or a lockfile-less `npm install` hoists the newest and the two `Page` types diverge under `tsc`) | +| @types/node, @types/react, @types/react-dom | 26.6.4, 19.3.0, 19.3.0 | + +The `@conusai-sdk/*` packages the template imports are pinned in +`package.json` at the version the SDK source tree carries at the same commit; +`test/sdk-deps.test.ts` fails when an imported package is missing or its pin +differs. The template ships **no `package-lock.json`** (decision of +2026-10-09, showcase plan D19): a lockfile without the scoped entries makes +`npm ci` fail, and one carrying them records `resolved` URLs of one install's +registry host, which is wrong on every other. The platform build runs +`npm install` against the `.npmrc` the deployer mounts as a BuildKit secret +(D6/D7), whose scoped registry serves exactly the pinned versions; +`test-template.sh` proves the same locally from the bundle's tarballs. + +## Layout + +- `app/` routes only; each file re-exports from `src/views`, `src/app` or a + feature's route handler. +- `src/app`, `src/views`, `src/widgets`, `src/features`, `src/entities`, + `src/shared`: the FSD layers, imports point downward only (checked by `steiger`). +- `src/shared/config/env.ts`: the environment, parsed once with zod (D12). +- `src/shared/api/*/server.ts`: server-only SDK clients (`server-only`). +- `src/shared/lib/visitor.ts`: the signed `sc_vid` visitor cookie (D16). + +## What each slice demonstrates + +| Slice | Demonstrates | +|---|---| +| `features/create-note` | A note in Postgres, audited as `note.created`; NATS announces it and Qdrant indexes it when those are linked. | +| `features/edit-note`, `features/delete-note` | Writes scoped to the author, each audited (`note.updated`, `note.deleted`). | +| `features/upload-file`, `features/delete-file` | Attachments in Blob Storage, one usage event per upload with an idempotency key; a per-minute quota when KV is linked. | +| `features/download-file` | An attachment's bytes at `/files/`, downloaded server-side through the Blob API and streamed to the visitor who attached it: the platform's blob URL needs the deployment token, so the browser cannot open it. | +| `features/publish-note-event` | An author re-announces a note on NATS (`note.announced` in the audit log); another visitor is refused. | +| `features/search-notes` | Similar-note search in Qdrant, when Qdrant is linked. | +| `features/export-notes` | The visitor's notes written as one JSON object to the dedicated S3 bucket, when S3 is linked. | +| `features/rate-limit` | The uploads left this minute, read from KV, when KV is linked. | +| `features/simulate-traffic` | A scripted visitor journey that produces observed page views, a started journey and an error. | +| `widgets/note-list`, `widgets/attachments` | The visitor's notes and their attachments, with the controls each feature provides. | +| `widgets/audit-timeline`, `widgets/usage-meter` | The platform's audit events for this project, and the visitor's metered uploads and bytes. | +| `widgets/enable-card`, `widgets/module-panel` | One slot per optional module: its feature when linked, its enable card (text from the platform's catalog) when not (D13). | +| `views/dashboard`, `views/audit`, `views/platform` | The composed pages; the platform page lists each optional service as linked or not. | + +## Optional modules + +The showcase reads the variables the platform injects for each linked service +(D8, D13). A module without its variables shows its enable card instead of its +feature. Its variables are `REDIS_URL` (KV), `NATS_URL` and `NATS_TOKEN`, +`QDRANT_URL` and `QDRANT_API_KEY`, `GIT_URL`, and `AWS_ENDPOINT_URL`, `S3_BUCKET`, +`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. The S3 names are the ones the platform +injects for an S3 service. + +## Commands + +- `npm run typecheck`, `npm run steiger`, `npm run lint`, `npm test`, `npm run build` +- `npm run e2e` runs the Playwright suite against a production build. It starts + `e2e/fake-platform.mjs` in place of the platform and withholds the optional + module variables, so every enable card shows. The suite covers creating a note + and its audit entry, an attachment moving the usage meter, the enable cards, + axe (no serious or critical issues on each page) and the Core Web Vitals + budgets (LCP at most 2.5 s, INP at most 200 ms) measured in Chromium. +- `npm test` also runs `src/shared/lib/optional.live.test.ts` against live NATS, + Qdrant and S3 when `NATS_URL`, `QDRANT_URL` and `AWS_ENDPOINT_URL` are set; without + them it is skipped. +- `bash scripts/vendor/test-template.sh` from the repository root runs all of them against the bundle; + `bash scripts/vendor/test-template.sh --e2e` adds the Playwright suite. diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..86e7497 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,17 @@ +import { sql } from 'drizzle-orm' +import { db, serverErrors, withRequest } from '@/shared/api' + +// 200 when Postgres answers; the platform probes this path (health_check_path). +export const dynamic = 'force-dynamic' + +export async function GET(request: Request) { + return withRequest(request, async () => { + try { + await db().execute(sql`select 1`) + return Response.json({ status: 'ok' }) + } catch (error) { + serverErrors().captureException(error) + return Response.json({ status: 'unavailable' }, { status: 503 }) + } + }) +} diff --git a/app/audit/page.tsx b/app/audit/page.tsx new file mode 100644 index 0000000..7425114 --- /dev/null +++ b/app/audit/page.tsx @@ -0,0 +1,3 @@ +export const dynamic = 'force-dynamic' + +export { AuditPage as default } from '@/views/audit' diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..a8bf1bf Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/files/[id]/route.ts b/app/files/[id]/route.ts new file mode 100644 index 0000000..fe6b5a8 --- /dev/null +++ b/app/files/[id]/route.ts @@ -0,0 +1 @@ +export { GET } from '@/features/download-file' diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..b9c9eac --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1 @@ +export { RootLayout as default, metadata } from '@/app/layouts/root' diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..8568c97 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1 @@ +export { DashboardPage as default } from '@/views/dashboard' diff --git a/app/platform/page.tsx b/app/platform/page.tsx new file mode 100644 index 0000000..3fe1ef0 --- /dev/null +++ b/app/platform/page.tsx @@ -0,0 +1,3 @@ +export const dynamic = 'force-dynamic' + +export { PlatformPage as default } from '@/views/platform' diff --git a/conusai.json b/conusai.json new file mode 100644 index 0000000..b681710 --- /dev/null +++ b/conusai.json @@ -0,0 +1,6 @@ +{ + "schema_version": 2, + "health_check": { + "path": "/api/health" + } +} diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..a4b6f1c --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + dialect: 'postgresql', + schema: ['./src/entities/note/model/schema.ts', './src/entities/file/model/schema.ts'], + out: './drizzle', +}) diff --git a/drizzle/0000_init.sql b/drizzle/0000_init.sql new file mode 100644 index 0000000..e488f17 --- /dev/null +++ b/drizzle/0000_init.sql @@ -0,0 +1,8 @@ +CREATE TABLE "notes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "title" text NOT NULL, + "body" text DEFAULT '' NOT NULL, + "author_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/drizzle/0001_files.sql b/drizzle/0001_files.sql new file mode 100644 index 0000000..190a917 --- /dev/null +++ b/drizzle/0001_files.sql @@ -0,0 +1,13 @@ +CREATE TABLE "files" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "note_id" uuid NOT NULL, + "author_id" text NOT NULL, + "name" text NOT NULL, + "content_type" text NOT NULL, + "size" integer NOT NULL, + "url" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +--> statement-breakpoint +ALTER TABLE "files" ADD CONSTRAINT "files_note_id_notes_id_fk" FOREIGN KEY ("note_id") REFERENCES "notes"("id") ON DELETE cascade ON UPDATE no action; diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..6b41d2f --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,72 @@ +{ + "id": "cbd1580b-e469-4710-a530-13305ad7a627", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..b457b21 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,135 @@ +{ + "id": "6c019c3b-9836-4d91-99a6-a1f20b9fe445", + "prevId": "cbd1580b-e469-4710-a530-13305ad7a627", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "note_id": { + "name": "note_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..e536d2e --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1791259122531, + "tag": "0000_init", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1791259684816, + "tag": "0001_files", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/e2e/observability.spec.ts b/e2e/observability.spec.ts new file mode 100644 index 0000000..f7450b1 --- /dev/null +++ b/e2e/observability.spec.ts @@ -0,0 +1,61 @@ +import { expect, test, type Page } from '@playwright/test' + +// Showcase D23: register() in instrumentation.ts starts the OTLP exporters when the platform +// injects OTEL_EXPORTER_OTLP_ENDPOINT, and every server action logs through shared/api/observe's +// logger(). This proves the whole chain against a real `next start` production server (not dev, +// not a mock of register()): a log a server action emits reaches the fake platform's +// /otel/v1/logs route within the exporter's 5 s batch interval. + +const platform = 'http://127.0.0.1:4010' + +interface LogRecordJson { + body?: { stringValue?: string } + attributes?: Array<{ key: string; value: { stringValue?: string } }> +} + +type OtelState = { otel: { logs: Array<{ authorization: string | null; body: { resourceLogs: Array<{ scopeLogs: Array<{ logRecords: LogRecordJson[] }> }> } }> } } + +async function otelLogRecords(): Promise { + const response = await fetch(`${platform}/__e2e/state`) + const state = (await response.json()) as OtelState + return state.otel.logs.flatMap((post) => post.body.resourceLogs.flatMap((r) => r.scopeLogs.flatMap((s) => s.logRecords))) +} + +function attr(record: LogRecordJson, key: string): string | undefined { + return record.attributes?.find((kv) => kv.key === key)?.value.stringValue +} + +async function createNote(page: Page, title: string): Promise { + await page.goto('/') + await page.getByLabel('Title').fill(title) + await page.getByLabel('Body').fill('observability e2e') + await page.getByRole('button', { name: 'Create note' }).click() + await expect(page.getByRole('heading', { name: title })).toBeVisible() +} + +test('a note.created server action log reaches the OTLP receiver within the batch interval', async ({ page }) => { + const title = `otlp ${Date.now()}-${Math.random().toString(36).slice(2, 7)}` + await createNote(page, title) + + await expect + .poll( + async () => (await otelLogRecords()).some((r) => r.body?.stringValue === 'note.created' && attr(r, 'outcome') === 'ok'), + // BatchExporter's default flush interval is 5 s (exporter.ts); this allows one full + // interval plus slack for the request round trip and CI scheduling jitter. + { timeout: 9_000 }, + ) + .toBe(true) +}) + +test('the OTLP request carries the deployment token, not the public client', async ({ page }) => { + const title = `otlp-auth ${Date.now()}-${Math.random().toString(36).slice(2, 7)}` + await createNote(page, title) + + await expect + .poll(async () => (await otelLogRecords()).some((r) => r.body?.stringValue === 'note.created'), { timeout: 9_000 }) + .toBe(true) + + const response = await fetch(`${platform}/__e2e/state`) + const state = (await response.json()) as OtelState + expect(state.otel.logs.every((post) => post.authorization === 'Bearer e2e-deployment-token')).toBe(true) +}) diff --git a/e2e/replay.spec.ts b/e2e/replay.spec.ts new file mode 100644 index 0000000..74e8efc --- /dev/null +++ b/e2e/replay.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test' + +// Proves, against a real production build (next build + next start, plan D18), that a +// visit actually uploads a session replay segment — not just that the SDK is configured +// to try. instrumentation-client.ts's /api/_conusai/* calls are same-origin and answered +// by the platform's edge on a real deployment (docs/how-to/use-platform-analytics-in-an- +// app.md §"Why this works"); this app has no edge in front of it here, so the test stands +// in for it with the minimum the SDK needs: a session id from /ids, and a place to post +// the envelope. This caught the stage regression (2026-10-11, showcase plan D24) where +// mode: 'always' alone produced zero /api/_conusai/envelope requests because replay is a +// 'consented' item and the default consent mode never sends those. + +const SESSION_ID = '11111111-1111-1111-1111-111111111111' + +test('a visit uploads a session replay segment within 15 s on a production build', async ({ page }) => { + let envelopePosts = 0 + + // Analytics, vitals and errors also call the same-origin /api/_conusai/* paths; answer + // them all generically so the run is quiet and deterministic, except the two the + // recorder actually needs. + await page.route('**/api/_conusai/**', (route) => { + const url = route.request().url() + if (url.includes('/api/_conusai/ids')) { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ v: 1, session_id: SESSION_ID }) }) + } + if (url.includes('/api/_conusai/envelope')) { + envelopePosts += 1 + return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }) + } + return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }) + }) + + await page.goto('/') + await page.getByLabel('Title').click() + await page.mouse.wheel(0, 200) + await page.goto('/audit') + await page.goto('/') + + await expect.poll(() => envelopePosts, { timeout: 15_000, message: 'no POST to /api/_conusai/envelope (replay segment)' }).toBeGreaterThan(0) +}) diff --git a/e2e/showcase.spec.ts b/e2e/showcase.spec.ts new file mode 100644 index 0000000..4d5c639 --- /dev/null +++ b/e2e/showcase.spec.ts @@ -0,0 +1,122 @@ +import AxeBuilder from '@axe-core/playwright' +import { expect, test, type Page } from '@playwright/test' + +// The end-to-end run of the showcase (plan S4.8): a note, an attachment, the audit +// entry and usage on the platform, the enable cards, accessibility and the Core Web +// Vitals budgets. The platform is the fake in e2e/fake-platform.mjs. + +const platform = 'http://127.0.0.1:4010' + +type PlatformState = { + audit: Array<{ action: string; resource_id: string | null }> + usage: Array<{ idempotency_key: string; meter: string }> +} + +async function platformState(): Promise { + const response = await fetch(`${platform}/__e2e/state`) + return (await response.json()) as PlatformState +} + +function uniqueTitle(label: string): string { + return `${label} ${Date.now()}-${Math.random().toString(36).slice(2, 7)}` +} + +async function createNote(page: Page, title: string): Promise { + await page.goto('/') + await page.getByLabel('Title').fill(title) + await page.getByLabel('Body').fill('written by the end-to-end run') + await page.getByRole('button', { name: 'Create note' }).click() + await expect(page.getByRole('heading', { name: title })).toBeVisible() +} + +test('a note is audited on the platform and listed on the audit page', async ({ page }) => { + const title = uniqueTitle('audited') + await createNote(page, title) + + await expect + .poll(async () => (await platformState()).audit.some((event) => event.action === 'note.created'), { timeout: 15_000 }) + .toBe(true) + + await page.goto('/audit') + await expect(page.getByText('note.created').first()).toBeVisible() +}) + +test('an attachment moves the usage meter and is metered on the platform', async ({ page }) => { + const title = uniqueTitle('attached') + await createNote(page, title) + + const meter = page.getByText(/uploads, \d+ bytes stored/) + await expect(meter).toHaveText(/^0 uploads, 0 bytes stored$/) + const usageBefore = (await platformState()).usage.length + + const note = page.getByRole('listitem').filter({ hasText: title }) + await note.getByLabel('Attachment').setInputFiles({ + name: 'notes.txt', + mimeType: 'text/plain', + buffer: Buffer.from('sixteen bytes!!!'), + }) + await note.getByRole('button', { name: 'Attach', exact: true }).click() + + await expect(meter).toHaveText(/^1 uploads, 16 bytes stored$/) + await expect + .poll(async () => (await platformState()).usage.length, { timeout: 15_000 }) + .toBeGreaterThan(usageBefore) + + // The attachment downloads through the app: the platform's blob URL needs the + // deployment token, which the browser does not have. + const href = await note.getByRole('link', { name: 'notes.txt' }).getAttribute('href') + expect(href).toMatch(/^\/files\/[0-9a-f-]+$/) + const download = await page.request.get(href ?? '') + expect(download.status()).toBe(200) + expect(await download.text()).toBe('sixteen bytes!!!') +}) + +test('each optional module without its variables shows its enable card', async ({ page }) => { + await page.goto('/') + + await expect(page.getByText(/to enable this\.$/)).toHaveCount(5) + await expect(page.getByText('Link KV Store to enable this.')).toBeVisible() + await expect(page.getByText(/uploads left this minute/)).toHaveCount(0) +}) + +for (const path of ['/', '/audit', '/platform']) { + test(`axe finds no serious or critical issues on ${path}`, async ({ page }) => { + await page.goto(path) + const results = await new AxeBuilder({ page }).analyze() + const blocking = results.violations + .filter((violation) => violation.impact === 'serious' || violation.impact === 'critical') + .map((violation) => `${violation.id} (${violation.nodes.length} node(s))`) + expect(blocking).toEqual([]) + }) +} + +test('the dashboard meets the Core Web Vitals budgets: LCP at most 2.5 s, INP at most 200 ms', async ({ page }) => { + await page.addInitScript(() => { + const vitals = window as unknown as { lcp: number; inp: number } + vitals.lcp = 0 + vitals.inp = 0 + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) vitals.lcp = entry.startTime + }).observe({ type: 'largest-contentful-paint', buffered: true }) + // Event Timing entries carry the interaction id and the duration INP is built from. + new PerformanceObserver((list) => { + for (const entry of list.getEntries() as Array) { + if ((entry.interactionId ?? 0) > 0) vitals.inp = Math.max(vitals.inp, entry.duration) + } + }).observe({ type: 'event', buffered: true, durationThreshold: 16 } as PerformanceObserverInit) + }) + + await page.goto('/', { waitUntil: 'load' }) + await page.getByLabel('Title').fill(uniqueTitle('vitals')) + await page.getByLabel('Body').fill('an interaction for the input delay budget') + await page.getByRole('button', { name: 'Create note' }).click() + await expect(page.getByText('an interaction for the input delay budget')).toBeVisible() + + const vitals = await page.evaluate(() => { + const measured = window as unknown as { lcp: number; inp: number } + return { lcp: measured.lcp, inp: measured.inp } + }) + expect(vitals.lcp).toBeGreaterThan(0) + expect(vitals.lcp).toBeLessThanOrEqual(2500) + expect(vitals.inp).toBeLessThanOrEqual(200) +}) diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..8fb73a6 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,18 @@ +import nextVitals from 'eslint-config-next/core-web-vitals' +import nextTypescript from 'eslint-config-next/typescript' + +const config = [ + { ignores: ['.next/**', 'node_modules/**', 'drizzle/**'] }, + ...nextVitals, + ...nextTypescript, + { + rules: { + // `useActionState`'s action takes (state, formData); an action that only + // needs the latter still has to name the former. `_`-prefixed is the + // call site's way of saying "unused on purpose". + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + }, + }, +] + +export default config diff --git a/instrumentation-client.ts b/instrumentation-client.ts new file mode 100644 index 0000000..38c76d8 --- /dev/null +++ b/instrumentation-client.ts @@ -0,0 +1,34 @@ +// Runs in the browser before the app. On the platform the page's own origin is +// the collector, so no configuration is needed here. +// +// Replay mode 'always' (showcase plan D22): the SDK's default ('onError') only uploads a +// replay when an error is captured or replay.flush() is called, so a plain showcase visit +// with no error produces nothing to show on Analytics > Session Replays. 'always' streams +// from page start regardless of errors, so every visit is recorded. +// +// consent: 'opt-out' (showcase plan D24): replay (like identify) is a 'consented' item +// (@conusai-sdk/observe's core/consent.ts verdict()), and the default consent mode, +// 'anonymous', never sends consented items — mode: 'always' alone does not start the +// recorder at all, because canRecord() gates on core.permits('consented'). The showcase +// has no consent banner (plan D16: no user auth), so 'opt-out' starts the session granted; +// GPC/DNT still revokes it (respectGPC defaults to true). +// +// Every module explicit, on purpose (showcase plan D25): @conusai-sdk/observe +// enables only `errors` by default — analytics, vitals, traces, replay and logs +// are each off unless named under `modules`. A showcase that forgot one would +// show nothing on that console page after a real visit (first seen 2026-10-11, +// stage: Analytics read 0 visitors/0 sessions and Observe read "Traces off"/ +// "Audit off" because only replay was enabled). test/instrumentation-client.test.ts +// fails if any of the five is left off or set to a bare `false`. +import { instrumentationClient } from '@conusai-sdk/observe-next' + +instrumentationClient({ + consent: 'opt-out', + modules: { + analytics: true, + vitals: true, + traces: true, + replay: { mode: 'always' }, + logs: true, + }, +}) diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 0000000..78c2f39 --- /dev/null +++ b/instrumentation.ts @@ -0,0 +1,4 @@ +// Next calls this once per server process. The Node error client starts here (plan §4.4), and so +// do the OTLP log/trace exporters, when the platform injected OTEL_EXPORTER_OTLP_ENDPOINT (D23); +// shared/api/observe's `logger()` is how the rest of the app reaches the logger this starts. +export { register } from '@conusai-sdk/observe-next' diff --git a/migrate.mjs b/migrate.mjs new file mode 100644 index 0000000..5e0a29c --- /dev/null +++ b/migrate.mjs @@ -0,0 +1,16 @@ +// Applies the SQL migrations in drizzle/ before the app starts (plan D15). +// `npm start` runs it first, so a deploy never serves against an old schema. +import { drizzle } from 'drizzle-orm/postgres-js' +import { migrate } from 'drizzle-orm/postgres-js/migrator' +import postgres from 'postgres' + +const url = process.env.POSTGRES_URL +if (!url) { + console.error('POSTGRES_URL is required to apply migrations') + process.exit(1) +} + +const client = postgres(url, { max: 1 }) +await migrate(drizzle(client), { migrationsFolder: 'drizzle' }) +await client.end() +console.log('migrations applied') diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..ca54436 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,13 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + reactStrictMode: true, + poweredByHeader: false, + experimental: { + // Uploads post to a server action, whose body Next caps at 1 MB by default; + // the app accepts attachments up to 5 MiB (entities/file MAX_UPLOAD_BYTES). + serverActions: { bodySizeLimit: '6mb' }, + }, +} + +export default nextConfig diff --git a/package.json b/package.json new file mode 100644 index 0000000..5af1a2d --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "conusai-showcase", + "version": "0.1.0", + "private": true, + "description": "ConusAI Showcase: notes with attachments on Postgres and Blob Storage, every ConusAI SDK in one Next.js app.", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "node migrate.mjs && next start", + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "steiger": "steiger src", + "test": "vitest run", + "e2e": "playwright test", + "migrate": "node migrate.mjs", + "db:generate": "drizzle-kit generate" + }, + "dependencies": { + "@aws-sdk/client-s3": "3.1146.0", + "@conusai-sdk/api": "0.1.4-dev.20261027.g9b1131fb", + "@conusai-sdk/blob": "0.0.7-dev.20261027.g9b1131fb", + "@conusai-sdk/kv": "0.0.5-dev.20261027.g9b1131fb", + "@conusai-sdk/node-sdk": "0.2.6-dev.20261027.g9b1131fb", + "@conusai-sdk/observe": "0.1.0-dev.20261027.g9b1131fb", + "@conusai-sdk/observe-next": "0.1.0-dev.20261027.g9b1131fb", + "@conusai-sdk/observe-node": "0.1.1-dev.20261027.g9b1131fb", + "@conusai-sdk/observe-react": "0.1.0-dev.20261027.g9b1131fb", + "@conusai-sdk/usage": "0.1.1-dev.20261027.g9b1131fb", + "drizzle-orm": "0.45.3", + "nats": "2.29.3", + "next": "16.4.2", + "postgres": "3.4.9", + "react": "19.3.0", + "react-dom": "19.3.0", + "server-only": "0.0.1", + "zod": "4.6.5" + }, + "devDependencies": { + "@axe-core/playwright": "4.13.0", + "@conusai-sdk/observe-testing": "0.1.0-dev.20261027.g9b1131fb", + "@eslint/eslintrc": "3.3.7", + "@feature-sliced/steiger-plugin": "0.8.0", + "@playwright/test": "1.63.0", + "@types/node": "26.6.4", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "drizzle-kit": "0.31.11", + "eslint": "9.39.5", + "eslint-config-next": "16.3.8", + "playwright-core": "1.63.0", + "steiger": "0.7.0", + "typescript": "5.9.3", + "vite": "8.3.4", + "vitest": "5.0.3" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..f39204b --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,60 @@ +import { defineConfig } from '@playwright/test' + +// The app runs against @conusai-kit/testing's fake platform. The optional +// module variables are withheld, so the dashboard shows every enable card (plan D13). +const platform = 'http://127.0.0.1:4010' +const fakePlatformCommand = process.env.CONUSAI_FAKE_PLATFORM_COMMAND +if (!fakePlatformCommand) { + throw new Error('CONUSAI_FAKE_PLATFORM_COMMAND must run @conusai-kit/testing/fake-platform') +} +// The variables of the optional modules, withheld from the app under test. +const optional = new Set([ + 'NATS_URL', + 'NATS_TOKEN', + 'QDRANT_URL', + 'QDRANT_API_KEY', + 'REDIS_URL', + 'GIT_URL', + 'AWS_ENDPOINT_URL', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', +]) +const base = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined && !optional.has(entry[0])), +) + +export default defineConfig({ + testDir: 'e2e', + workers: 1, + reporter: [['list']], + use: { baseURL: 'http://localhost:3000' }, + webServer: [ + { + command: fakePlatformCommand, + url: `${platform}/__e2e/health`, + reuseExistingServer: false, + }, + { + command: 'npm run start', + url: 'http://localhost:3000/api/health', + reuseExistingServer: false, + timeout: 120_000, + env: { + ...base, + CONUSAI_API_URL: `${platform}/api`, + CONUSAI_API_TOKEN: 'e2e-deployment-token', + CONUSAI_PROJECT_ID: 'e2e', + CONUSAI_DSN: "http://e2e@127.0.0.1:4010/1", + CONUSAI_RELEASE: 'e2e', + USAGE_ORG_SLUG: 'e2e', + // Mirrors env_resolver.rs's OTEL_* derivation from CONUSAI_API_URL/CONUSAI_API_TOKEN + // (showcase D23): without these, register() never calls initOtlp() and the otlp e2e + // spec would pass for the wrong reason (nothing ever attempted, rather than delivered). + OTEL_EXPORTER_OTLP_ENDPOINT: `${platform}/api/otel`, + OTEL_EXPORTER_OTLP_PROTOCOL: 'http/protobuf', + OTEL_EXPORTER_OTLP_HEADERS: 'Authorization=Bearer e2e-deployment-token', + OTEL_SERVICE_NAME: 'showcase-e2e', + }, + }, + ], +}) diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..34ac1a6 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,4 @@ + + + C + diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..93afbad --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,232 @@ +/* Global stylesheet for the Showcase. + * + * The template deliberately ships no Tailwind or CSS framework (D11 keeps + * every layer dependency-light), so this file carries the whole design with + * element selectors only — no class names exist in the markup. Style new + * markup with semantic elements (main, section, aside, form, ol/ul, pre) + * rather than adding utility classes. + */ + +:root { + --bg: #f6f7f9; + --surface: #ffffff; + --border: #e2e5ea; + --text: #1a202c; + --muted: #5b6472; + --accent: #4f46e5; + --accent-hover: #4338ca; + --danger: #b91c1c; + --radius: 10px; + --shadow: 0 1px 2px rgb(16 24 40 / 0.06); +} + +* { + box-sizing: border-box; +} + +html { + font-family: + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + Helvetica, + Arial, + sans-serif; + line-height: 1.55; + color: var(--text); + background: var(--bg); +} + +body { + margin: 0; +} + +main { + max-width: 56rem; + margin: 0 auto; + padding: 2rem 1.25rem 4rem; +} + +h1 { + font-size: 1.75rem; + line-height: 1.2; + margin: 0 0 0.5rem; +} + +h2 { + font-size: 1.15rem; + margin: 1.75rem 0 0.5rem; +} + +p { + margin: 0.5rem 0; + color: var(--muted); +} + +main > p:first-of-type { + margin-top: 0; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* Forms: the create/edit note forms and every action button. */ + +form { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin: 1.25rem 0; +} + +label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.9rem; + font-weight: 500; + color: var(--text); +} + +input, +textarea { + font: inherit; + padding: 0.5rem 0.65rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); +} + +input:focus-visible, +textarea:focus-visible, +button:focus-visible, +a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +textarea { + resize: vertical; +} + +button { + align-self: flex-start; + font: inherit; + font-weight: 500; + padding: 0.5rem 1rem; + border: 1px solid transparent; + border-radius: var(--radius); + background: var(--accent); + color: #fff; + cursor: pointer; +} + +button:hover { + background: var(--accent-hover); +} + +form[action=''] + button, +main > button { + margin-bottom: 1.25rem; +} + +/* Lists: notes (ul) and the audit timeline (ol). */ + +ul, +ol { + list-style: none; + margin: 1rem 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +ul li, +ol li { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 0.9rem 1rem; +} + +ul li h2 { + margin-top: 0; +} + +time { + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +/* The module panel: one card per optional service, enable cards included. */ + +section { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: 0.9rem; + margin: 1.5rem 0; +} + +section > div { + display: flex; +} + +section > div > aside, +section > div > form { + flex: 1; + margin: 0; +} + +section aside { + background: var(--surface); + border: 1px dashed var(--border); + border-radius: var(--radius); + padding: 0.9rem 1rem; +} + +section aside p { + margin: 0; +} + +section aside p + p { + margin-top: 0.35rem; + font-size: 0.85rem; +} + +section form { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.9rem 1rem; +} + +section button { + margin: 0; +} + +/* The platform page's raw description payload. */ + +pre { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.9rem 1rem; + overflow-x: auto; + font-size: 0.85rem; +} + +@media (max-width: 40rem) { + main { + padding: 1.25rem 1rem 3rem; + } +} diff --git a/src/app/layouts/root.tsx b/src/app/layouts/root.tsx new file mode 100644 index 0000000..11a52f0 --- /dev/null +++ b/src/app/layouts/root.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' +import { Observe } from '@/app/observability/observe' +import '../globals.css' + +/** The document title every page inherits (axe: document-title). */ +export const metadata: Metadata = { + title: 'ConusAI Showcase', + description: 'Notes, attachments, audit and usage on the ConusAI platform', + // `app/favicon.ico` and `app/icon.svg` already serve `/favicon.ico` and + // `/icon.svg` through Next's own file convention; `icons.icon` here is what + // makes the page's own tag name `/favicon.svg` (served from + // `public/favicon.svg`) instead of `/icon.svg`, since both paths 404'd on + // every visit before this (stage sweep, 2026-10-11). + icons: { + icon: [ + { url: '/favicon.svg', type: 'image/svg+xml' }, + { url: '/favicon.ico', sizes: 'any' }, + ], + }, +} + +export function RootLayout({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ) +} diff --git a/src/app/observability/observe.tsx b/src/app/observability/observe.tsx new file mode 100644 index 0000000..87ebf14 --- /dev/null +++ b/src/app/observability/observe.tsx @@ -0,0 +1,13 @@ +'use client' + +import type { ReactNode } from 'react' +import { ErrorBoundary, ObserveProvider } from '@conusai-sdk/observe-react' + +/** The browser observability root: the SDK instance, and a boundary that reports what React catches. */ +export function Observe({ children }: { children: ReactNode }) { + return ( + + Something went wrong. The error has been reported.

}>{children}
+
+ ) +} diff --git a/src/entities/file/api/repository.ts b/src/entities/file/api/repository.ts new file mode 100644 index 0000000..7390165 --- /dev/null +++ b/src/entities/file/api/repository.ts @@ -0,0 +1,50 @@ +import { and, eq } from 'drizzle-orm' +import type { drizzle } from 'drizzle-orm/postgres-js' +import { files, type FileRow } from '@/entities/file/model/schema' + +/** The handle the shared `db()` returns (`@/shared/api`). */ +type Db = ReturnType + +export type NewFile = Omit + +/** + * Attachment persistence. Writes name their author, as the notes repository does: + * a visitor removes only the files they attached. + */ +export function fileRepository(db: Db) { + return { + async create(input: NewFile): Promise { + const [row] = await db.insert(files).values(input).returning() + if (!row) throw new Error('the insert returned no row') + return row + }, + + async listByNote(noteId: string): Promise { + return db.select().from(files).where(eq(files.noteId, noteId)) + }, + + /** The attachment, or `undefined` when `authorId` did not attach it. */ + async get(id: string, authorId: string): Promise { + const [row] = await db + .select() + .from(files) + .where(and(eq(files.id, id), eq(files.authorId, authorId))) + .limit(1) + return row + }, + + /** Every attachment the visitor made, across their notes. */ + async listByAuthor(authorId: string): Promise { + return db.select().from(files).where(eq(files.authorId, authorId)) + }, + + /** The removed file, or `undefined` when `authorId` did not attach it. */ + async remove(id: string, authorId: string): Promise { + const [row] = await db + .delete(files) + .where(and(eq(files.id, id), eq(files.authorId, authorId))) + .returning() + return row + }, + } +} diff --git a/src/entities/file/index.ts b/src/entities/file/index.ts new file mode 100644 index 0000000..e30e753 --- /dev/null +++ b/src/entities/file/index.ts @@ -0,0 +1,4 @@ +export { files, type FileRow } from './model/schema' +export { fileId } from './model/id' +export { MAX_UPLOAD_BYTES, ALLOWED_CONTENT_TYPES, uploadLimits, type UploadCandidate } from './model/limits' +export { fileRepository, type NewFile } from './api/repository' diff --git a/src/entities/file/model/id.ts b/src/entities/file/model/id.ts new file mode 100644 index 0000000..0d79c01 --- /dev/null +++ b/src/entities/file/model/id.ts @@ -0,0 +1,4 @@ +import { z } from 'zod' + +/** A file id as it arrives in a URL or a form: a UUID, nothing else. */ +export const fileId = z.uuid() diff --git a/src/entities/file/model/limits.test.ts b/src/entities/file/model/limits.test.ts new file mode 100644 index 0000000..a02d99d --- /dev/null +++ b/src/entities/file/model/limits.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { MAX_UPLOAD_BYTES, uploadLimits } from '@/entities/file' + +describe('uploadLimits', () => { + const ok = { name: 'plan.pdf', size: 1024, contentType: 'application/pdf' } + + it('accepts an allowed type within the size limit', () => { + expect(uploadLimits.parse(ok)).toEqual(ok) + }) + + it('refuses a file over the size limit', () => { + expect(() => uploadLimits.parse({ ...ok, size: MAX_UPLOAD_BYTES + 1 })).toThrow(/5 MiB/) + }) + + it('refuses an empty file', () => { + expect(() => uploadLimits.parse({ ...ok, size: 0 })).toThrow(/empty/) + }) + + it('refuses a content type that is not on the list', () => { + expect(() => uploadLimits.parse({ ...ok, contentType: 'application/x-msdownload' })).toThrow(/not accepted/) + }) +}) diff --git a/src/entities/file/model/limits.ts b/src/entities/file/model/limits.ts new file mode 100644 index 0000000..801a9d5 --- /dev/null +++ b/src/entities/file/model/limits.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' + +/** The largest attachment accepted, in bytes (5 MiB). */ +export const MAX_UPLOAD_BYTES = 5 * 1024 * 1024 + +/** The content types an attachment may have. Anything else is refused before upload. */ +export const ALLOWED_CONTENT_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'application/pdf', + 'text/plain', + 'text/markdown', +] as const + +export const uploadLimits = z.object({ + name: z.string().trim().min(1, 'A file needs a name').max(255), + size: z.number().int().min(1, 'The file is empty').max(MAX_UPLOAD_BYTES, 'The file is larger than 5 MiB'), + contentType: z.enum(ALLOWED_CONTENT_TYPES, { error: 'That file type is not accepted' }), +}) + +export type UploadCandidate = z.infer diff --git a/src/entities/file/model/schema.ts b/src/entities/file/model/schema.ts new file mode 100644 index 0000000..3b94caa --- /dev/null +++ b/src/entities/file/model/schema.ts @@ -0,0 +1,19 @@ +import { integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core' + +/** + * An attachment on a note, stored in Blob Storage. `note_id` is a foreign key to + * notes(id) with ON DELETE CASCADE, added by the migration: an entity cannot + * import another entity's table (FSD), so the schema here carries the column only. + */ +export const files = pgTable('files', { + id: uuid('id').primaryKey().defaultRandom(), + noteId: uuid('note_id').notNull(), + authorId: text('author_id').notNull(), + name: text('name').notNull(), + contentType: text('content_type').notNull(), + size: integer('size').notNull(), + url: text('url').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export type FileRow = typeof files.$inferSelect diff --git a/src/entities/note/api/repository.test.ts b/src/entities/note/api/repository.test.ts new file mode 100644 index 0000000..09d4e23 --- /dev/null +++ b/src/entities/note/api/repository.test.ts @@ -0,0 +1,68 @@ +import { randomUUID } from 'node:crypto' +import { drizzle } from 'drizzle-orm/postgres-js' +import { migrate } from 'drizzle-orm/postgres-js/migrator' +import postgres from 'postgres' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { noteRepository, type NoteDb } from '@/entities/note' + +// Runs against a real Postgres: POSTGRES_URL must name one. Each run migrates a +// schema of its own and drops it afterwards, so runs never share rows. +const url = process.env.POSTGRES_URL +const schema = `note_test_${randomUUID().replaceAll('-', '')}` +let admin: postgres.Sql +let client: postgres.Sql +let repo: ReturnType + +const ALICE = 'visitor-alice' +const BOB = 'visitor-bob' + +beforeAll(async () => { + expect(url, 'POSTGRES_URL must name a test Postgres').toBeTruthy() + admin = postgres(url as string, { max: 1 }) + await admin.unsafe(`create schema "${schema}"`) + client = postgres(url as string, { max: 1, connection: { search_path: schema } }) + const db = drizzle(client) as NoteDb + await migrate(db, { migrationsFolder: 'drizzle', migrationsSchema: schema }) + repo = noteRepository(db) +}) + +afterAll(async () => { + await client?.end() + await admin?.unsafe(`drop schema if exists "${schema}" cascade`) + await admin?.end() +}) + +describe('noteRepository', () => { + it('creates a note and reads it back with its author', async () => { + const created = await repo.create({ title: 'Plan', body: 'draft', authorId: ALICE }) + const read = await repo.get(created.id) + expect(read).toMatchObject({ title: 'Plan', body: 'draft', authorId: ALICE }) + }) + + it('lists only the notes of the asked-for author', async () => { + await repo.create({ title: 'Bob note', body: '', authorId: BOB }) + const aliceNotes = await repo.listByAuthor(ALICE) + expect(aliceNotes.length).toBeGreaterThan(0) + expect(aliceNotes.every((note) => note.authorId === ALICE)).toBe(true) + }) + + it('lets the author update their own note', async () => { + const created = await repo.create({ title: 'Old', body: '', authorId: ALICE }) + const updated = await repo.update(created.id, ALICE, { title: 'New', body: 'edited' }) + expect(updated).toMatchObject({ id: created.id, title: 'New', body: 'edited' }) + }) + + it('does not let another visitor update the note', async () => { + const created = await repo.create({ title: 'Mine', body: '', authorId: ALICE }) + const attempt = await repo.update(created.id, BOB, { title: 'Hijacked', body: '' }) + expect(attempt).toBeUndefined() + expect((await repo.get(created.id))?.title).toBe('Mine') + }) + + it('removes only for the author', async () => { + const created = await repo.create({ title: 'Doomed', body: '', authorId: ALICE }) + expect(await repo.remove(created.id, BOB)).toBe(false) + expect(await repo.remove(created.id, ALICE)).toBe(true) + expect(await repo.get(created.id)).toBeUndefined() + }) +}) diff --git a/src/entities/note/api/repository.ts b/src/entities/note/api/repository.ts new file mode 100644 index 0000000..83202d8 --- /dev/null +++ b/src/entities/note/api/repository.ts @@ -0,0 +1,59 @@ +import { and, desc, eq } from 'drizzle-orm' +import type { drizzle } from 'drizzle-orm/postgres-js' +import { notes, type Note } from '@/entities/note/model/schema' +import type { NoteInput } from '@/entities/note/model/input' + +/** The handle the shared `db()` returns (`@/shared/api/db/server`). */ +export type NoteDb = ReturnType + +/** + * Note persistence over one database handle. The handle is a parameter so a test + * can run the same code against its own Postgres. + * + * Every write names its author in the `where`: a visitor can change only the + * notes they wrote (tenancy, D16). + */ +export function noteRepository(db: NoteDb) { + return { + async create(input: NoteInput & { authorId: string }): Promise { + const [row] = await db.insert(notes).values(input).returning() + if (!row) throw new Error('the insert returned no row') + return row + }, + + async get(id: string): Promise { + const [row] = await db.select().from(notes).where(eq(notes.id, id)).limit(1) + return row + }, + + async listByAuthor(authorId: string, limit = 50): Promise { + return db + .select() + .from(notes) + .where(eq(notes.authorId, authorId)) + .orderBy(desc(notes.createdAt)) + .limit(limit) + }, + + /** The updated note, or `undefined` when `authorId` did not write it. */ + async update(id: string, authorId: string, input: NoteInput): Promise { + const [row] = await db + .update(notes) + .set({ ...input, updatedAt: new Date() }) + .where(and(eq(notes.id, id), eq(notes.authorId, authorId))) + .returning() + return row + }, + + /** `true` when the note existed and `authorId` wrote it. */ + async remove(id: string, authorId: string): Promise { + const rows = await db + .delete(notes) + .where(and(eq(notes.id, id), eq(notes.authorId, authorId))) + .returning({ id: notes.id }) + return rows.length > 0 + }, + } +} + +export type NoteRepository = ReturnType diff --git a/src/entities/note/index.ts b/src/entities/note/index.ts new file mode 100644 index 0000000..22a86ba --- /dev/null +++ b/src/entities/note/index.ts @@ -0,0 +1,3 @@ +export { notes, type Note } from './model/schema' +export { noteInput, noteId, type NoteInput } from './model/input' +export { noteRepository, type NoteDb, type NoteRepository } from './api/repository' diff --git a/src/entities/note/model/input.test.ts b/src/entities/note/model/input.test.ts new file mode 100644 index 0000000..004eec1 --- /dev/null +++ b/src/entities/note/model/input.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { noteId, noteInput } from '@/entities/note' + +describe('noteInput', () => { + it('trims the title and defaults the body', () => { + expect(noteInput.parse({ title: ' Plan ' })).toEqual({ title: 'Plan', body: '' }) + }) + + it('refuses a blank title', () => { + expect(() => noteInput.parse({ title: ' ' })).toThrow(/title/) + }) + + it('refuses a title past 200 characters', () => { + expect(() => noteInput.parse({ title: 'x'.repeat(201) })).toThrow() + }) +}) + +describe('noteId', () => { + it('accepts a UUID and refuses anything else', () => { + expect(noteId.parse('00000000-0000-4000-8000-000000000001')).toBe('00000000-0000-4000-8000-000000000001') + expect(() => noteId.parse('not-a-uuid')).toThrow() + }) +}) diff --git a/src/entities/note/model/input.ts b/src/entities/note/model/input.ts new file mode 100644 index 0000000..819888b --- /dev/null +++ b/src/entities/note/model/input.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' + +/** The fields a visitor writes. Parsed at the boundary, before any write (plan §4.4). */ +export const noteInput = z.object({ + title: z.string().trim().min(1, 'A note needs a title').max(200), + body: z.string().max(20000).default(''), +}) + +export type NoteInput = z.infer + +/** A note id as it arrives in a form or a URL: a UUID, nothing else. */ +export const noteId = z.uuid() diff --git a/src/entities/note/model/schema.ts b/src/entities/note/model/schema.ts new file mode 100644 index 0000000..76c6923 --- /dev/null +++ b/src/entities/note/model/schema.ts @@ -0,0 +1,13 @@ +import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core' + +/** A note. `author_id` is the visitor's id from the signed `sc_vid` cookie (D16). */ +export const notes = pgTable('notes', { + id: uuid('id').primaryKey().defaultRandom(), + title: text('title').notNull(), + body: text('body').notNull().default(''), + authorId: text('author_id').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export type Note = typeof notes.$inferSelect diff --git a/src/features/create-note/api/actions.test.ts b/src/features/create-note/api/actions.test.ts new file mode 100644 index 0000000..d4f10c3 --- /dev/null +++ b/src/features/create-note/api/actions.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// The action is the unit under test: its collaborators are replaced, so each +// test asserts what the action wrote and audited, and nothing else. +const mocks = vi.hoisted(() => { + const row = { id: '00000000-0000-4000-8000-000000000001', title: 'Plan', body: '', authorId: 'visitor-1' } + const values = vi.fn(() => ({ returning: async () => [row] })) + const insert = vi.fn(() => ({ values })) + return { + row, + values, + insert, + log: vi.fn(async () => {}), + publishNoteCreated: vi.fn(async () => {}), + indexNote: vi.fn(async () => {}), + revalidatePath: vi.fn(), + loggerInfo: vi.fn(), + loggerRecordException: vi.fn(), + } +}) + +vi.mock('next/cache', () => ({ revalidatePath: mocks.revalidatePath })) +vi.mock('@/shared/api', () => ({ + db: () => ({ insert: mocks.insert }), + audit: () => ({ log: mocks.log }), + serverErrors: () => ({ captureException: vi.fn() }), + logger: () => ({ info: mocks.loggerInfo, recordException: mocks.loggerRecordException }), +})) +vi.mock('@/shared/lib/optional', () => ({ + publishNoteCreated: mocks.publishNoteCreated, + indexNote: mocks.indexNote, +})) +vi.mock('@/shared/lib/visitor-session', () => ({ ensureVisitor: async () => 'visitor-1' })) + +import { createNote } from '@/features/create-note/api/actions' + +function form(fields: Record): FormData { + const data = new FormData() + for (const [key, value] of Object.entries(fields)) data.set(key, value) + return data +} + +describe('createNote', () => { + beforeEach(() => { + mocks.values.mockClear() + mocks.insert.mockClear() + mocks.log.mockClear() + mocks.revalidatePath.mockClear() + mocks.loggerInfo.mockClear() + mocks.loggerRecordException.mockClear() + }) + + it('writes the note under the visitor and audits exactly one note.created', async () => { + await createNote(form({ title: 'Plan', body: '' })) + + expect(mocks.values).toHaveBeenCalledWith({ title: 'Plan', body: '', authorId: 'visitor-1' }) + expect(mocks.log).toHaveBeenCalledTimes(1) + expect(mocks.log).toHaveBeenCalledWith('note.created', { + actor: { kind: 'human', id: 'visitor-1' }, + target: { id: mocks.row.id }, + }) + expect(mocks.revalidatePath).toHaveBeenCalledWith('/') + expect(mocks.loggerInfo).toHaveBeenCalledTimes(1) + expect(mocks.loggerInfo).toHaveBeenCalledWith('note.created', { 'note.id': mocks.row.id, outcome: 'ok' }) + expect(mocks.loggerRecordException).not.toHaveBeenCalled() + }) + + it('refuses an invalid title before any write or audit, and logs the failure', async () => { + await expect(createNote(form({ title: ' ' }))).rejects.toThrow() + expect(mocks.insert).not.toHaveBeenCalled() + expect(mocks.log).not.toHaveBeenCalled() + expect(mocks.loggerInfo).not.toHaveBeenCalled() + expect(mocks.loggerRecordException).toHaveBeenCalledTimes(1) + expect(mocks.loggerRecordException).toHaveBeenCalledWith(expect.any(Error), { 'conusai.action': 'note.created' }) + }) + + it('announces and indexes the new note when the optional modules are linked', async () => { + await createNote(form({ title: 'Plan', body: 'draft' })) + expect(mocks.publishNoteCreated).toHaveBeenCalledWith({ id: mocks.row.id, title: 'Plan' }) + expect(mocks.indexNote).toHaveBeenCalledWith({ id: mocks.row.id, title: 'Plan', body: '' }) + }) +}) diff --git a/src/features/create-note/api/actions.ts b/src/features/create-note/api/actions.ts new file mode 100644 index 0000000..c308598 --- /dev/null +++ b/src/features/create-note/api/actions.ts @@ -0,0 +1,28 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { noteInput, noteRepository } from '@/entities/note' +import { audit, db, logger, serverErrors } from '@/shared/api' +import { indexNote, publishNoteCreated } from '@/shared/lib/optional' +import { ensureVisitor } from '@/shared/lib/visitor-session' + +/** Parse, write, audit, revalidate (plan §4.4): the shape every mutation follows. */ +export async function createNote(form: FormData): Promise { + try { + const input = noteInput.parse({ title: form.get('title'), body: form.get('body') ?? '' }) + const actor = await ensureVisitor() + const note = await noteRepository(db()).create({ ...input, authorId: actor }) + await audit().log('note.created', { actor: { kind: 'human', id: actor }, target: { id: note.id } }) + logger()?.info('note.created', { 'note.id': note.id, outcome: 'ok' }) + // Optional modules: NATS hears of the note and Qdrant indexes it when linked. A failure is + // reported, not raised: the note is saved. + await Promise.all([ + publishNoteCreated({ id: note.id, title: note.title }), + indexNote({ id: note.id, title: note.title, body: note.body }), + ]).catch((error: unknown) => serverErrors().captureException(error)) + revalidatePath('/') + } catch (error) { + logger()?.recordException(error, { 'conusai.action': 'note.created' }) + throw error + } +} diff --git a/src/features/create-note/index.ts b/src/features/create-note/index.ts new file mode 100644 index 0000000..c0f13fa --- /dev/null +++ b/src/features/create-note/index.ts @@ -0,0 +1,2 @@ +export { createNote } from './api/actions' +export { CreateNoteForm } from './ui/CreateNoteForm' diff --git a/src/features/create-note/ui/CreateNoteForm.tsx b/src/features/create-note/ui/CreateNoteForm.tsx new file mode 100644 index 0000000..31ebc21 --- /dev/null +++ b/src/features/create-note/ui/CreateNoteForm.tsx @@ -0,0 +1,17 @@ +import { createNote } from '@/features/create-note/api/actions' + +export function CreateNoteForm() { + return ( +
+ +