feat(projects): list + create API and creatable ProjectAutocomplete (T4)
- 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.
This commit is contained in:
2
server/db/migrations/0001_boring_randall.sql
Normal file
2
server/db/migrations/0001_boring_randall.sql
Normal file
@@ -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"));
|
||||
189
server/db/migrations/meta/0001_snapshot.json
Normal file
189
server/db/migrations/meta/0001_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
44
server/db/repositories/projects.ts
Normal file
44
server/db/repositories/projects.ts
Normal file
@@ -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<typeof schema>
|
||||
|
||||
export type Project = typeof projects.$inferSelect
|
||||
|
||||
/** The shared, global project list, alphabetical by name (F3). */
|
||||
export async function listProjects(db: Db): Promise<Project[]> {
|
||||
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<Project> {
|
||||
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<Project | undefined> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(sql`lower(${projects.name}) = lower(${name})`)
|
||||
.limit(1)
|
||||
return row
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user