From 1194edcf7566432c74bb0ae086bd2c357b16dcfb Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Wed, 27 May 2026 23:28:03 +0200 Subject: [PATCH] feat(projects): list + create API and creatable ProjectAutocomplete (T4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/projects returns the shared global list; POST /api/projects creates a project, deduped case-insensitively (F3). - Repository find-or-create trims and matches on lower(name); a unique index on lower(name) enforces the same rule at the DB (replacing the case- sensitive column unique constraint) — migration 0001. - ProjectAutocomplete: creatable autocomplete that lists projects, offers to create an unknown name inline, and selects it without reload. - Integration test (dedupe/list against Postgres) + component test. --- app/components/ProjectAutocomplete.vue | 148 +++++++++++++++ server/api/projects.get.ts | 7 + server/api/projects.post.ts | 12 ++ server/db/migrations/0001_boring_randall.sql | 2 + server/db/migrations/meta/0001_snapshot.json | 189 +++++++++++++++++++ server/db/migrations/meta/_journal.json | 7 + server/db/repositories/projects.ts | 44 +++++ server/db/schema.ts | 18 +- tasks/todo.md | 2 +- tests/project-autocomplete.nuxt.spec.ts | 93 +++++++++ tests/projects.spec.ts | 70 +++++++ 11 files changed, 585 insertions(+), 7 deletions(-) create mode 100644 app/components/ProjectAutocomplete.vue create mode 100644 server/api/projects.get.ts create mode 100644 server/api/projects.post.ts create mode 100644 server/db/migrations/0001_boring_randall.sql create mode 100644 server/db/migrations/meta/0001_snapshot.json create mode 100644 server/db/repositories/projects.ts create mode 100644 tests/project-autocomplete.nuxt.spec.ts create mode 100644 tests/projects.spec.ts diff --git a/app/components/ProjectAutocomplete.vue b/app/components/ProjectAutocomplete.vue new file mode 100644 index 0000000..f71fcbf --- /dev/null +++ b/app/components/ProjectAutocomplete.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/server/api/projects.get.ts b/server/api/projects.get.ts new file mode 100644 index 0000000..f72fb8a --- /dev/null +++ b/server/api/projects.get.ts @@ -0,0 +1,7 @@ +import { getDb } from '../db/client' +import { listProjects } from '../db/repositories/projects' + +// The shared, global project list (F3). +export default defineEventHandler(async () => { + return listProjects(getDb()) +}) diff --git a/server/api/projects.post.ts b/server/api/projects.post.ts new file mode 100644 index 0000000..e9fcf85 --- /dev/null +++ b/server/api/projects.post.ts @@ -0,0 +1,12 @@ +import { getDb } from '../db/client' +import { createProject } from '../db/repositories/projects' + +// Create a project (case-insensitively deduped) and return it (F3). +export default defineEventHandler(async (event) => { + const body = await readBody<{ name?: unknown }>(event) + const name = typeof body?.name === 'string' ? body.name.trim() : '' + if (!name) { + throw createError({ statusCode: 400, statusMessage: 'Project name is required' }) + } + return createProject(getDb(), name) +}) diff --git a/server/db/migrations/0001_boring_randall.sql b/server/db/migrations/0001_boring_randall.sql new file mode 100644 index 0000000..3cdd58b --- /dev/null +++ b/server/db/migrations/0001_boring_randall.sql @@ -0,0 +1,2 @@ +ALTER TABLE "projects" DROP CONSTRAINT "projects_name_unique";--> statement-breakpoint +CREATE UNIQUE INDEX "projects_name_lower_idx" ON "projects" USING btree (lower("name")); \ No newline at end of file diff --git a/server/db/migrations/meta/0001_snapshot.json b/server/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..a0a2292 --- /dev/null +++ b/server/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,189 @@ +{ + "id": "16079f27-2c98-41cc-a954-d09599102874", + "prevId": "a68da650-95df-4918-9def-4cccec67a7bf", + "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": { + "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 c739586..b6a8c75 100644 --- a/server/db/migrations/meta/_journal.json +++ b/server/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1779913917235, "tag": "0000_loving_black_bird", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1779916898127, + "tag": "0001_boring_randall", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/db/repositories/projects.ts b/server/db/repositories/projects.ts new file mode 100644 index 0000000..401d4b3 --- /dev/null +++ b/server/db/repositories/projects.ts @@ -0,0 +1,44 @@ +import { asc, sql } from 'drizzle-orm' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import type * as schema from '../schema' +import { projects } from '../schema' + +type Db = NodePgDatabase + +export type Project = typeof projects.$inferSelect + +/** The shared, global project list, alphabetical by name (F3). */ +export async function listProjects(db: Db): Promise { + return db.select().from(projects).orderBy(asc(projects.name)) +} + +/** + * Find-or-create a project by name, deduping case-insensitively so "Acme" and + * "acme" resolve to one project (F3). The DB enforces the same rule via a + * unique index on `lower(name)`; the catch handles a concurrent insert race. + */ +export async function createProject(db: Db, rawName: string): Promise { + const name = rawName.trim() + if (!name) throw new Error('Project name is required') + + const existing = await findByName(db, name) + if (existing) return existing + + try { + const [row] = await db.insert(projects).values({ name }).returning() + return row! + } catch (err) { + const row = await findByName(db, name) + if (row) return row + throw err + } +} + +async function findByName(db: Db, name: string): Promise { + const [row] = await db + .select() + .from(projects) + .where(sql`lower(${projects.name}) = lower(${name})`) + .limit(1) + return row +} diff --git a/server/db/schema.ts b/server/db/schema.ts index 8d1b31d..e26466f 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -1,14 +1,20 @@ // 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 { pgTable, uuid, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' 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 projects = pgTable( + 'projects', + { + id: uuid('id').primaryKey().$defaultFn(randomUUID), + name: text('name').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + // Case-insensitive uniqueness: "Acme" and "acme" are the same project (F3). + (t) => [uniqueIndex('projects_name_lower_idx').on(sql`lower(${t.name})`)], +) export const defects = pgTable('defects', { id: uuid('id').primaryKey().$defaultFn(randomUUID), diff --git a/tasks/todo.md b/tasks/todo.md index 1db3c16..2326148 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -11,7 +11,7 @@ review. ## Phase 2 — File a defect (G1) - [x] **T3** Postgres schema + migrations (C8) (S) — _deps: T1_ -- [ ] **T4** Projects API + ProjectAutocomplete (C4) — F3 (M) — _deps: T3_ +- [x] **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 diff --git a/tests/project-autocomplete.nuxt.spec.ts b/tests/project-autocomplete.nuxt.spec.ts new file mode 100644 index 0000000..ac77f45 --- /dev/null +++ b/tests/project-autocomplete.nuxt.spec.ts @@ -0,0 +1,93 @@ +// @vitest-environment nuxt +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime' +import { flushPromises } from '@vue/test-utils' +import { defineEventHandler, readBody } from 'h3' +import ProjectAutocomplete from '../app/components/ProjectAutocomplete.vue' + +interface Row { + id: string + name: string +} + +let store: Row[] + +beforeEach(() => { + store = [ + { id: '1', name: 'Acme' }, + { id: '2', name: 'Globex' }, + ] + // One endpoint backing both list (GET) and creatable autocomplete (POST), + // deduping case-insensitively like the real API. + registerEndpoint( + '/api/projects', + defineEventHandler(async (event) => { + if (event.method === 'GET') return store + const { name } = await readBody<{ name: string }>(event) + const trimmed = name.trim() + const existing = store.find( + (p) => p.name.toLowerCase() === trimmed.toLowerCase(), + ) + if (existing) return existing + const created = { id: String(store.length + 1), name: trimmed } + store.push(created) + return created + }), + ) +}) + +describe('ProjectAutocomplete', () => { + it('lists the existing projects when opened', async () => { + const wrapper = await mountSuspended(ProjectAutocomplete) + await flushPromises() + + await wrapper.find('[data-test="project-input"]').trigger('focus') + const names = wrapper + .findAll('[data-test="project-option"]') + .map((el) => el.text()) + expect(names).toContain('Acme') + expect(names).toContain('Globex') + }) + + it('offers to create a project that does not exist yet', async () => { + const wrapper = await mountSuspended(ProjectAutocomplete) + await flushPromises() + + const input = wrapper.find('[data-test="project-input"]') + await input.setValue('Initech') + expect(wrapper.find('[data-test="project-create"]').exists()).toBe(true) + }) + + it('does not offer to create a name that already exists (case-insensitive)', async () => { + const wrapper = await mountSuspended(ProjectAutocomplete) + await flushPromises() + + await wrapper.find('[data-test="project-input"]').setValue('acme') + expect(wrapper.find('[data-test="project-create"]').exists()).toBe(false) + }) + + it('persists a new project, selects it, and makes it selectable without reload', async () => { + const wrapper = await mountSuspended(ProjectAutocomplete) + await flushPromises() + + await wrapper.find('[data-test="project-input"]').setValue('Initech') + await wrapper.find('[data-test="project-create"]').trigger('click') + + // Selected and emitted to the parent (the POST resolves asynchronously). + await vi.waitFor(() => { + expect(wrapper.emitted('update:modelValue')).toBeTruthy() + }) + await flushPromises() + const emitted = wrapper.emitted('update:modelValue')! + expect(emitted.at(-1)![0]).toMatchObject({ name: 'Initech' }) + + // Persisted to the store and now selectable without reload — reopening the + // list (the create flow closes it) shows the new project. + expect(store.map((p) => p.name)).toContain('Initech') + await wrapper.find('[data-test="project-input"]').trigger('focus') + const names = wrapper + .findAll('[data-test="project-option"]') + .map((el) => el.text()) + expect(names).toContain('Initech') + }) +}) diff --git a/tests/projects.spec.ts b/tests/projects.spec.ts new file mode 100644 index 0000000..40f275b --- /dev/null +++ b/tests/projects.spec.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest' +import { setupTestDb, truncateAll, type TestDb } from './helpers/db' +import { createProject, listProjects } from '../server/db/repositories/projects' + +let db: TestDb +let pool: { end: () => Promise } + +beforeAll(async () => { + const setup = await setupTestDb() + db = setup.db + pool = setup.pool +}) + +afterAll(async () => { + await pool.end() +}) + +beforeEach(async () => { + await truncateAll(db) +}) + +describe('projects repository', () => { + it('lists nothing when no projects exist', async () => { + expect(await listProjects(db)).toEqual([]) + }) + + it('creates a project and lists it', async () => { + const created = await createProject(db, 'Acme') + expect(created.id).toBeDefined() + expect(created.name).toBe('Acme') + + const list = await listProjects(db) + expect(list).toHaveLength(1) + expect(list[0]!.name).toBe('Acme') + }) + + it('dedupes case-insensitively — "Acme" then "acme" yields one project', async () => { + const first = await createProject(db, 'Acme') + const second = await createProject(db, 'acme') + + expect(second.id).toBe(first.id) + expect(second.name).toBe('Acme') // keeps the original casing + expect(await listProjects(db)).toHaveLength(1) + }) + + it('trims surrounding whitespace and dedupes against the trimmed name', async () => { + const first = await createProject(db, 'Globex') + const second = await createProject(db, ' globex ') + + expect(second.id).toBe(first.id) + expect(await listProjects(db)).toHaveLength(1) + }) + + it('rejects a blank name', async () => { + await expect(createProject(db, ' ')).rejects.toThrow() + expect(await listProjects(db)).toHaveLength(0) + }) + + it('lists projects alphabetically by name', async () => { + await createProject(db, 'Zebra') + await createProject(db, 'Acme') + await createProject(db, 'Monarch') + + expect((await listProjects(db)).map((p) => p.name)).toEqual([ + 'Acme', + 'Monarch', + 'Zebra', + ]) + }) +})