feat(defects): read API — feed, per-section counts and history (T6)

GET /api/defects returns a newest-first feed joined to the project name,
bounded by limit; ?section=ID narrows to one section's history. GET
/api/defects/counts returns { sectionId: n } for the weak-point map. Adds a
(section_id, created_at) index and a db:seed script; ~2k rows query in ~20ms.
This commit is contained in:
Julien Calixte
2026-05-28 00:18:18 +02:00
parent fc958ba59f
commit 999593b426
11 changed files with 384 additions and 14 deletions

View File

@@ -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",

12
server/api/defects.get.ts Normal file
View File

@@ -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,
})
})

View File

@@ -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())
})

View File

@@ -0,0 +1 @@
CREATE INDEX "defects_section_created_idx" ON "defects" USING btree ("section_id","created_at" DESC NULLS LAST);

View File

@@ -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": {}
}
}

View File

@@ -15,6 +15,13 @@
"when": 1779916898127,
"tag": "0001_boring_randall",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1779920052613,
"tag": "0002_mixed_chronomancer",
"breakpoints": true
}
]
}

View File

@@ -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<typeof schema>
@@ -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<DefectView[]> {
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<Record<string, number>> {
const rows = await db
.select({ sectionId: defects.sectionId, count: sql<number>`count(*)::int` })
.from(defects)
.groupBy(defects.sectionId)
return Object.fromEntries(rows.map((r) => [r.sectionId, r.count]))
}

View File

@@ -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),

23
server/db/seed.ts Normal file
View File

@@ -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()

View File

@@ -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

View File

@@ -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<void> }
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,
})
})
})