diff --git a/package.json b/package.json index a74b799..83a2ea0 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "typecheck": "nuxt typecheck", "db:generate": "drizzle-kit generate", "db:migrate": "tsx server/db/migrate.ts", + "db:seed": "tsx server/db/seed.ts", "db:verify": "tsx scripts/verify-db.ts", "docker:dev": "docker compose -f docker-compose.dev.yml up", "docker:dev:build": "docker compose -f docker-compose.dev.yml up --build", diff --git a/server/api/defects.get.ts b/server/api/defects.get.ts new file mode 100644 index 0000000..41d061e --- /dev/null +++ b/server/api/defects.get.ts @@ -0,0 +1,12 @@ +import { getDb } from '../db/client' +import { listDefects } from '../db/repositories/defects' + +// Defect feed (F6), newest-first. `?section=ID` narrows to one section's +// history (the lazy modal load); `?limit=N` bounds the slice. +export default defineEventHandler(async (event) => { + const { section, limit } = getQuery(event) + return listDefects(getDb(), { + sectionId: typeof section === 'string' ? section : undefined, + limit: typeof limit === 'string' ? Number(limit) || undefined : undefined, + }) +}) diff --git a/server/api/defects/counts.get.ts b/server/api/defects/counts.get.ts new file mode 100644 index 0000000..d4b834f --- /dev/null +++ b/server/api/defects/counts.get.ts @@ -0,0 +1,7 @@ +import { getDb } from '../../db/client' +import { countDefectsBySection } from '../../db/repositories/defects' + +// Per-section defect counts `{ sectionId: n }` for the weak-point map (F4/F6). +export default defineEventHandler(async () => { + return countDefectsBySection(getDb()) +}) diff --git a/server/db/migrations/0002_mixed_chronomancer.sql b/server/db/migrations/0002_mixed_chronomancer.sql new file mode 100644 index 0000000..b4a663e --- /dev/null +++ b/server/db/migrations/0002_mixed_chronomancer.sql @@ -0,0 +1 @@ +CREATE INDEX "defects_section_created_idx" ON "defects" USING btree ("section_id","created_at" DESC NULLS LAST); \ No newline at end of file diff --git a/server/db/migrations/meta/0002_snapshot.json b/server/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..10e1c97 --- /dev/null +++ b/server/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,211 @@ +{ + "id": "03c0f924-93f1-4194-8e63-2bc4e37f7a8d", + "prevId": "16079f27-2c98-41cc-a954-d09599102874", + "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": { + "defects_section_created_idx": { + "name": "defects_section_created_idx", + "columns": [ + { + "expression": "section_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": { + "projects_name_lower_idx": { + "name": "projects_name_lower_idx", + "columns": [ + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 index b6a8c75..613b84b 100644 --- a/server/db/migrations/meta/_journal.json +++ b/server/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1779916898127, "tag": "0001_boring_randall", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1779920052613, + "tag": "0002_mixed_chronomancer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/db/repositories/defects.ts b/server/db/repositories/defects.ts index ff48910..643ef53 100644 --- a/server/db/repositories/defects.ts +++ b/server/db/repositories/defects.ts @@ -1,7 +1,7 @@ -import { desc } from 'drizzle-orm' +import { desc, eq, sql } from 'drizzle-orm' import type { NodePgDatabase } from 'drizzle-orm/node-postgres' import type * as schema from '../schema' -import { defects } from '../schema' +import { defects, projects } from '../schema' type Db = NodePgDatabase @@ -22,3 +22,48 @@ export async function createDefect(db: Db, input: NewDefect) { export async function listRecentDefects(db: Db, limit = 100) { return db.select().from(defects).orderBy(desc(defects.createdAt)).limit(limit) } + +export interface DefectView { + id: string + sectionId: string + projectId: string + projectName: string + verbatim: string + reporterEmail: string + createdAt: Date +} + +/** + * Defects for the dashboard (F6), newest-first and joined to the project name. + * The whole feed by default; one section's history when `sectionId` is given + * (the lazy modal load). Always bounded by `limit`. + */ +export async function listDefects( + db: Db, + opts: { sectionId?: string; limit?: number } = {}, +): Promise { + return db + .select({ + id: defects.id, + sectionId: defects.sectionId, + projectId: defects.projectId, + projectName: projects.name, + verbatim: defects.verbatim, + reporterEmail: defects.reporterEmail, + createdAt: defects.createdAt, + }) + .from(defects) + .innerJoin(projects, eq(projects.id, defects.projectId)) + .where(opts.sectionId ? eq(defects.sectionId, opts.sectionId) : undefined) + .orderBy(desc(defects.createdAt)) + .limit(opts.limit ?? 100) +} + +/** Per-section defect counts `{ sectionId: n }` for the weak-point map (F4/F6). */ +export async function countDefectsBySection(db: Db): Promise> { + const rows = await db + .select({ sectionId: defects.sectionId, count: sql`count(*)::int` }) + .from(defects) + .groupBy(defects.sectionId) + return Object.fromEntries(rows.map((r) => [r.sectionId, r.count])) +} diff --git a/server/db/schema.ts b/server/db/schema.ts index e26466f..971897d 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -1,7 +1,7 @@ // 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, uniqueIndex } from 'drizzle-orm/pg-core' +import { pgTable, uuid, text, timestamp, uniqueIndex, index } from 'drizzle-orm/pg-core' import { sql } from 'drizzle-orm' import { randomUUID } from 'node:crypto' @@ -16,16 +16,21 @@ export const projects = pgTable( (t) => [uniqueIndex('projects_name_lower_idx').on(sql`lower(${t.name})`)], ) -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 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(), + }, + // Serves the per-section history and counts, and the newest-first feed (F6). + (t) => [index('defects_section_created_idx').on(t.sectionId, t.createdAt.desc())], +) export const pushSubscriptions = pgTable('push_subscriptions', { id: uuid('id').primaryKey().$defaultFn(randomUUID), diff --git a/server/db/seed.ts b/server/db/seed.ts new file mode 100644 index 0000000..28a4487 --- /dev/null +++ b/server/db/seed.ts @@ -0,0 +1,23 @@ +// Seed defects for perf-checking the dashboard (F6 target: ≤1s at ~2k rows). +// SEED_COUNT=2000 pnpm db:seed +import { getDb, getPool } from './client' +import { createProject } from './repositories/projects' +import { defects } from './schema' +import { sections } from '../../app/board/definition' + +const count = Number(process.env.SEED_COUNT ?? 2000) +const db = getDb() + +const project = await createProject(db, 'Seed Project') +const rows = Array.from({ length: count }, (_, i) => ({ + sectionId: sections[i % sections.length]!.id, + projectId: project.id, + verbatim: `seeded defect #${i}`, + reporterEmail: 'seed@theodo.com', + // Spread timestamps so the feed ordering is meaningful. + createdAt: new Date(Date.now() - i * 60_000), +})) + +await db.insert(defects).values(rows) +console.log(`[seed] inserted ${count} defects`) +await getPool().end() diff --git a/tasks/todo.md b/tasks/todo.md index 92c73e0..853107b 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -16,7 +16,7 @@ review. - [ ] ⛳ **Checkpoint:** defect files end-to-end (dev auth), tests pass, human review ## Phase 3 — See weak points (G2) -- [ ] **T6** Defects read API: feed + per-section counts (C7) — F6 (M) — _deps: T3_ +- [x] **T6** Defects read API: feed + per-section counts (C7) — F6 (M) — _deps: T3_ - [ ] **T7** Dashboard DotMap (C5) — F4 (M) — _deps: T2, T6_ - [ ] **T8** VerbatimModal + Feed (C6) — F5 (M) — _deps: T6, T7_ - [ ] ⛳ **Checkpoint:** dashboard dot map + feed + modal work, ≤1s @2k, human review diff --git a/tests/defects-read.spec.ts b/tests/defects-read.spec.ts new file mode 100644 index 0000000..60251e3 --- /dev/null +++ b/tests/defects-read.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest' +import { setupTestDb, truncateAll, type TestDb } from './helpers/db' +import { createProject } from '../server/db/repositories/projects' +import { listDefects, countDefectsBySection } from '../server/db/repositories/defects' +import { defects } from '../server/db/schema' + +let db: TestDb +let pool: { end: () => Promise } + +beforeAll(async () => { + const setup = await setupTestDb() + db = setup.db + pool = setup.pool +}) + +afterAll(async () => { + await pool.end() +}) + +// Three defects across two sections with controlled timestamps: +// macroplan: "old" (Jan 1), "new" (Feb 1) +// feature-kanban: "kanban" (Jan 15) +beforeEach(async () => { + await truncateAll(db) + const project = await createProject(db, 'Acme') + await db.insert(defects).values([ + { sectionId: 'macroplan', projectId: project.id, verbatim: 'old', reporterEmail: 'a@theodo.com', createdAt: new Date('2026-01-01T00:00:00Z') }, + { sectionId: 'macroplan', projectId: project.id, verbatim: 'new', reporterEmail: 'a@theodo.com', createdAt: new Date('2026-02-01T00:00:00Z') }, + { sectionId: 'feature-kanban', projectId: project.id, verbatim: 'kanban', reporterEmail: 'a@theodo.com', createdAt: new Date('2026-01-15T00:00:00Z') }, + ]) +}) + +describe('listDefects', () => { + it('returns the feed newest-first with the project name', async () => { + const feed = await listDefects(db) + expect(feed.map((d) => d.verbatim)).toEqual(['new', 'kanban', 'old']) + expect(feed[0]!.projectName).toBe('Acme') + }) + + it('filters to a single section when given one', async () => { + const feed = await listDefects(db, { sectionId: 'macroplan' }) + expect(feed.map((d) => d.verbatim)).toEqual(['new', 'old']) + }) + + it('bounds the feed to the requested limit', async () => { + const feed = await listDefects(db, { limit: 1 }) + expect(feed.map((d) => d.verbatim)).toEqual(['new']) + }) +}) + +describe('countDefectsBySection', () => { + it('returns a per-section count map', async () => { + expect(await countDefectsBySection(db)).toEqual({ + macroplan: 2, + 'feature-kanban': 1, + }) + }) +})