diff --git a/package.json b/package.json index cd80200..48aac6c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "test:watch": "vitest", "typecheck": "nuxt typecheck", "db:generate": "drizzle-kit generate", - "db:migrate": "tsx server/db/migrate.ts" + "db:migrate": "tsx server/db/migrate.ts", + "db:verify": "tsx scripts/verify-db.ts" }, "dependencies": { "drizzle-orm": "^0.45.2", diff --git a/scripts/verify-db.ts b/scripts/verify-db.ts new file mode 100644 index 0000000..fcf3fb1 --- /dev/null +++ b/scripts/verify-db.ts @@ -0,0 +1,37 @@ +// Verifies the schema + repository against a real Postgres (DATABASE_URL). +// Applies migrations, round-trips a defect, and prints the result. +// DATABASE_URL=postgres://andon:andon@localhost:5432/andon pnpm db:verify +import assert from 'node:assert/strict' +import { migrate } from 'drizzle-orm/node-postgres/migrator' +import { getDb, getPool } from '../server/db/client' +import { defects, projects } from '../server/db/schema' +import { createDefect, listRecentDefects } from '../server/db/repositories/defects' + +const db = getDb() + +await migrate(db, { migrationsFolder: 'server/db/migrations' }) + +// Clean slate so the script is idempotent. +await db.delete(defects) +await db.delete(projects) + +const [project] = await db.insert(projects).values({ name: 'Acme' }).returning() +assert.ok(project, 'project insert returned a row') + +const defect = await createDefect(db, { + sectionId: 'macroplan', + projectId: project.id, + verbatim: 'Macroplan is out of date', + reporterEmail: 'dev@theodo.com', +}) +assert.ok(defect.id, 'defect has an id') +assert.equal(defect.sectionId, 'macroplan') +assert.equal(defect.projectId, project.id) +assert.ok(defect.createdAt instanceof Date, 'createdAt is a Date') + +const recent = await listRecentDefects(db) +assert.equal(recent.length, 1) +assert.equal(recent[0]!.verbatim, 'Macroplan is out of date') + +console.log(`DB verify OK — defect ${defect.id} on section "${defect.sectionId}"`) +await getPool().end() diff --git a/server/db/client.ts b/server/db/client.ts index 6436a95..e2e9d03 100644 --- a/server/db/client.ts +++ b/server/db/client.ts @@ -1,6 +1,9 @@ import pg from 'pg' +import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres' +import * as schema from './schema' let pool: pg.Pool | undefined +let db: NodePgDatabase | undefined /** Lazily-created shared Postgres connection pool. */ export function getPool(): pg.Pool { @@ -10,6 +13,14 @@ export function getPool(): pg.Pool { return pool } +/** Lazily-created Drizzle client bound to the shared pool. */ +export function getDb(): NodePgDatabase { + if (!db) { + db = drizzle(getPool(), { schema }) + } + return db +} + /** Liveness probe for the database, used by /api/health. */ export async function checkDb(): Promise<'up' | 'down'> { try { diff --git a/server/db/migrate.ts b/server/db/migrate.ts new file mode 100644 index 0000000..633a5a5 --- /dev/null +++ b/server/db/migrate.ts @@ -0,0 +1,7 @@ +// Applies pending migrations. Run via `pnpm db:migrate`, and on container +// boot in production (wired in Task 12). +import { migrate } from 'drizzle-orm/node-postgres/migrator' +import { getDb, getPool } from './client' + +await migrate(getDb(), { migrationsFolder: 'server/db/migrations' }) +await getPool().end() diff --git a/server/db/migrations/0000_loving_black_bird.sql b/server/db/migrations/0000_loving_black_bird.sql new file mode 100644 index 0000000..e74b530 --- /dev/null +++ b/server/db/migrations/0000_loving_black_bird.sql @@ -0,0 +1,27 @@ +CREATE TABLE "defects" ( + "id" uuid PRIMARY KEY NOT NULL, + "section_id" text NOT NULL, + "project_id" uuid NOT NULL, + "verbatim" text NOT NULL, + "reporter_email" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "projects" ( + "id" uuid PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "projects_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE "push_subscriptions" ( + "id" uuid PRIMARY KEY NOT NULL, + "endpoint" text NOT NULL, + "p256dh" text NOT NULL, + "auth" text NOT NULL, + "reporter_email" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "push_subscriptions_endpoint_unique" UNIQUE("endpoint") +); +--> statement-breakpoint +ALTER TABLE "defects" ADD CONSTRAINT "defects_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/server/db/migrations/meta/0000_snapshot.json b/server/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..ad44178 --- /dev/null +++ b/server/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,181 @@ +{ + "id": "a68da650-95df-4918-9def-4cccec67a7bf", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.defects": { + "name": "defects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "verbatim": { + "name": "verbatim", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reporter_email": { + "name": "reporter_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "defects_project_id_projects_id_fk": { + "name": "defects_project_id_projects_id_fk", + "tableFrom": "defects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "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": { + "projects_name_unique": { + "name": "projects_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reporter_email": { + "name": "reporter_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "nullsNotDistinct": false, + "columns": [ + "endpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/db/migrations/meta/_journal.json b/server/db/migrations/meta/_journal.json new file mode 100644 index 0000000..c739586 --- /dev/null +++ b/server/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1779913917235, + "tag": "0000_loving_black_bird", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/server/db/repositories/defects.ts b/server/db/repositories/defects.ts new file mode 100644 index 0000000..ff48910 --- /dev/null +++ b/server/db/repositories/defects.ts @@ -0,0 +1,24 @@ +import { desc } from 'drizzle-orm' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import type * as schema from '../schema' +import { defects } from '../schema' + +type Db = NodePgDatabase + +export interface NewDefect { + sectionId: string + projectId: string + verbatim: string + reporterEmail: string +} + +/** Persist a filed defect and return the stored row. */ +export async function createDefect(db: Db, input: NewDefect) { + const [row] = await db.insert(defects).values(input).returning() + return row +} + +/** Recent defects, newest first — the dashboard feed (F5/F6). */ +export async function listRecentDefects(db: Db, limit = 100) { + return db.select().from(defects).orderBy(desc(defects.createdAt)).limit(limit) +} diff --git a/server/db/schema.ts b/server/db/schema.ts index 4c80391..8d1b31d 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -1,2 +1,31 @@ -// Drizzle (Postgres) schema — tables are defined in Task 3. -export {} +// Drizzle (Postgres) schema (C8). +// `section_id` is a plain string referencing the in-code board section ids +// (C1) — no foreign key, because sections live in code, not the DB (ADR 0001). +import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core' +import { randomUUID } from 'node:crypto' + +export const projects = pgTable('projects', { + id: uuid('id').primaryKey().$defaultFn(randomUUID), + name: text('name').notNull().unique(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const defects = pgTable('defects', { + id: uuid('id').primaryKey().$defaultFn(randomUUID), + sectionId: text('section_id').notNull(), + projectId: uuid('project_id') + .notNull() + .references(() => projects.id), + verbatim: text('verbatim').notNull(), + reporterEmail: text('reporter_email').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const pushSubscriptions = pgTable('push_subscriptions', { + id: uuid('id').primaryKey().$defaultFn(randomUUID), + endpoint: text('endpoint').notNull().unique(), + p256dh: text('p256dh').notNull(), + auth: text('auth').notNull(), + reporterEmail: text('reporter_email'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) diff --git a/tasks/todo.md b/tasks/todo.md index b7f752a..1db3c16 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -10,7 +10,7 @@ review. - [ ] ⛳ **Checkpoint:** build + tests pass, board interactive, human review ## Phase 2 — File a defect (G1) -- [ ] **T3** Postgres schema + migrations (C8) (S) — _deps: T1_ +- [x] **T3** Postgres schema + migrations (C8) (S) — _deps: T1_ - [ ] **T4** Projects API + ProjectAutocomplete (C4) — F3 (M) — _deps: T3_ - [ ] **T5** File a defect: POST /api/defects + DefectForm (C3) — F2 (M) — _deps: T2, T3, T4_ - [ ] ⛳ **Checkpoint:** defect files end-to-end (dev auth), tests pass, human review