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',
+ ])
+ })
+})