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:
@@ -15,6 +15,7 @@
|
|||||||
"typecheck": "nuxt typecheck",
|
"typecheck": "nuxt typecheck",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "tsx server/db/migrate.ts",
|
"db:migrate": "tsx server/db/migrate.ts",
|
||||||
|
"db:seed": "tsx server/db/seed.ts",
|
||||||
"db:verify": "tsx scripts/verify-db.ts",
|
"db:verify": "tsx scripts/verify-db.ts",
|
||||||
"docker:dev": "docker compose -f docker-compose.dev.yml up",
|
"docker:dev": "docker compose -f docker-compose.dev.yml up",
|
||||||
"docker:dev:build": "docker compose -f docker-compose.dev.yml up --build",
|
"docker:dev:build": "docker compose -f docker-compose.dev.yml up --build",
|
||||||
|
|||||||
12
server/api/defects.get.ts
Normal file
12
server/api/defects.get.ts
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
7
server/api/defects/counts.get.ts
Normal file
7
server/api/defects/counts.get.ts
Normal 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())
|
||||||
|
})
|
||||||
1
server/db/migrations/0002_mixed_chronomancer.sql
Normal file
1
server/db/migrations/0002_mixed_chronomancer.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
CREATE INDEX "defects_section_created_idx" ON "defects" USING btree ("section_id","created_at" DESC NULLS LAST);
|
||||||
211
server/db/migrations/meta/0002_snapshot.json
Normal file
211
server/db/migrations/meta/0002_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1779916898127,
|
"when": 1779916898127,
|
||||||
"tag": "0001_boring_randall",
|
"tag": "0001_boring_randall",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1779920052613,
|
||||||
|
"tag": "0002_mixed_chronomancer",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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 { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||||
import type * as schema from '../schema'
|
import type * as schema from '../schema'
|
||||||
import { defects } from '../schema'
|
import { defects, projects } from '../schema'
|
||||||
|
|
||||||
type Db = NodePgDatabase<typeof 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) {
|
export async function listRecentDefects(db: Db, limit = 100) {
|
||||||
return db.select().from(defects).orderBy(desc(defects.createdAt)).limit(limit)
|
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]))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Drizzle (Postgres) schema (C8).
|
// Drizzle (Postgres) schema (C8).
|
||||||
// `section_id` is a plain string referencing the in-code board section ids
|
// `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).
|
// (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 { sql } from 'drizzle-orm'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
|
|
||||||
@@ -16,7 +16,9 @@ export const projects = pgTable(
|
|||||||
(t) => [uniqueIndex('projects_name_lower_idx').on(sql`lower(${t.name})`)],
|
(t) => [uniqueIndex('projects_name_lower_idx').on(sql`lower(${t.name})`)],
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defects = pgTable('defects', {
|
export const defects = pgTable(
|
||||||
|
'defects',
|
||||||
|
{
|
||||||
id: uuid('id').primaryKey().$defaultFn(randomUUID),
|
id: uuid('id').primaryKey().$defaultFn(randomUUID),
|
||||||
sectionId: text('section_id').notNull(),
|
sectionId: text('section_id').notNull(),
|
||||||
projectId: uuid('project_id')
|
projectId: uuid('project_id')
|
||||||
@@ -25,7 +27,10 @@ export const defects = pgTable('defects', {
|
|||||||
verbatim: text('verbatim').notNull(),
|
verbatim: text('verbatim').notNull(),
|
||||||
reporterEmail: text('reporter_email').notNull(),
|
reporterEmail: text('reporter_email').notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
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', {
|
export const pushSubscriptions = pgTable('push_subscriptions', {
|
||||||
id: uuid('id').primaryKey().$defaultFn(randomUUID),
|
id: uuid('id').primaryKey().$defaultFn(randomUUID),
|
||||||
|
|||||||
23
server/db/seed.ts
Normal file
23
server/db/seed.ts
Normal 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()
|
||||||
@@ -16,7 +16,7 @@ review.
|
|||||||
- [ ] ⛳ **Checkpoint:** defect files end-to-end (dev auth), tests pass, human review
|
- [ ] ⛳ **Checkpoint:** defect files end-to-end (dev auth), tests pass, human review
|
||||||
|
|
||||||
## Phase 3 — See weak points (G2)
|
## 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_
|
- [ ] **T7** Dashboard DotMap (C5) — F4 (M) — _deps: T2, T6_
|
||||||
- [ ] **T8** VerbatimModal + Feed (C6) — F5 (M) — _deps: T6, T7_
|
- [ ] **T8** VerbatimModal + Feed (C6) — F5 (M) — _deps: T6, T7_
|
||||||
- [ ] ⛳ **Checkpoint:** dashboard dot map + feed + modal work, ≤1s @2k, human review
|
- [ ] ⛳ **Checkpoint:** dashboard dot map + feed + modal work, ≤1s @2k, human review
|
||||||
|
|||||||
58
tests/defects-read.spec.ts
Normal file
58
tests/defects-read.spec.ts
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user