diff --git a/backend/.env.example b/backend/.env.example index 8959738..249bcbb 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,6 +11,9 @@ BETTER_AUTH_URL="http://localhost:3000" PORT=4000 NODE_ENV=development +# IANA time zone for day boundaries (start of day, due today). Defaults to UTC when unset. +TIMEZONE="Africa/Lagos" + # Seed accounts (optional overrides for `npm run seed`) SEED_PA_EMAIL="pa@example.com" SEED_CEO_EMAIL="ceo@example.com" diff --git a/backend/prisma/migrations/20261010130847_domain_data_model/migration.sql b/backend/prisma/migrations/20261010130847_domain_data_model/migration.sql new file mode 100644 index 0000000..6daf530 --- /dev/null +++ b/backend/prisma/migrations/20261010130847_domain_data_model/migration.sql @@ -0,0 +1,224 @@ +-- AlterTable +ALTER TABLE "user" ADD COLUMN "role" TEXT NOT NULL DEFAULT 'PA'; + +-- CheckConstraint (hand written: Prisma has no schema syntax for CHECK) +ALTER TABLE "user" ADD CONSTRAINT "user_role_check" CHECK ("role" IN ('PA', 'CEO')); + +-- CreateTable +CREATE TABLE "Meeting" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "startsAt" TIMESTAMP(3) NOT NULL, + "endsAt" TIMESTAMP(3) NOT NULL, + "location" TEXT, + "notes" TEXT, + "status" TEXT NOT NULL DEFAULT 'scheduled', + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "Meeting_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MeetingAttendee" ( + "meetingId" TEXT NOT NULL, + "contactId" TEXT NOT NULL, + "roleLabel" TEXT, + + CONSTRAINT "MeetingAttendee_pkey" PRIMARY KEY ("meetingId","contactId") +); + +-- CreateTable +CREATE TABLE "Contact" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "company" TEXT, + "email" TEXT, + "phone" TEXT, + "notes" TEXT, + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "Contact_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Task" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "dueAt" TIMESTAMP(3), + "priority" TEXT NOT NULL DEFAULT 'medium', + "status" TEXT NOT NULL DEFAULT 'open', + "assigneeId" TEXT NOT NULL, + "createdById" TEXT NOT NULL, + "relatedContactId" TEXT, + "relatedMeetingId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "Task_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FollowUp" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "dueAt" TIMESTAMP(3), + "status" TEXT NOT NULL DEFAULT 'pending', + "assigneeId" TEXT NOT NULL, + "sourceContactId" TEXT, + "sourceMeetingId" TEXT, + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "FollowUp_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PendingDecision" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "context" TEXT, + "status" TEXT NOT NULL DEFAULT 'pending', + "dueAt" TIMESTAMP(3), + "relatedMeetingId" TEXT, + "decidedById" TEXT, + "decisionComment" TEXT, + "decidedAt" TIMESTAMP(3), + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "PendingDecision_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Attachment" ( + "id" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "storagePath" TEXT NOT NULL, + "parentType" TEXT NOT NULL, + "parentId" TEXT NOT NULL, + "uploadedById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Attachment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Notification" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "title" TEXT NOT NULL, + "body" TEXT, + "relatedType" TEXT, + "relatedId" TEXT, + "readAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Activity" ( + "id" TEXT NOT NULL, + "entityType" TEXT NOT NULL, + "entityId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "actorId" TEXT, + "payload" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Activity_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Meeting_startsAt_idx" ON "Meeting"("startsAt"); + +-- CreateIndex +CREATE INDEX "Contact_name_idx" ON "Contact"("name"); + +-- CreateIndex +CREATE INDEX "Task_status_dueAt_idx" ON "Task"("status", "dueAt"); + +-- CreateIndex +CREATE INDEX "FollowUp_dueAt_idx" ON "FollowUp"("dueAt"); + +-- CreateIndex +CREATE INDEX "PendingDecision_status_dueAt_idx" ON "PendingDecision"("status", "dueAt"); + +-- CreateIndex +CREATE INDEX "Attachment_parentType_parentId_idx" ON "Attachment"("parentType", "parentId"); + +-- CreateIndex +CREATE INDEX "Notification_userId_readAt_idx" ON "Notification"("userId", "readAt"); + +-- CreateIndex +CREATE INDEX "Activity_entityType_entityId_idx" ON "Activity"("entityType", "entityId"); + +-- AddForeignKey +ALTER TABLE "Meeting" ADD CONSTRAINT "Meeting_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MeetingAttendee" ADD CONSTRAINT "MeetingAttendee_meetingId_fkey" FOREIGN KEY ("meetingId") REFERENCES "Meeting"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MeetingAttendee" ADD CONSTRAINT "MeetingAttendee_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Contact" ADD CONSTRAINT "Contact_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_relatedContactId_fkey" FOREIGN KEY ("relatedContactId") REFERENCES "Contact"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_relatedMeetingId_fkey" FOREIGN KEY ("relatedMeetingId") REFERENCES "Meeting"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FollowUp" ADD CONSTRAINT "FollowUp_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FollowUp" ADD CONSTRAINT "FollowUp_sourceContactId_fkey" FOREIGN KEY ("sourceContactId") REFERENCES "Contact"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FollowUp" ADD CONSTRAINT "FollowUp_sourceMeetingId_fkey" FOREIGN KEY ("sourceMeetingId") REFERENCES "Meeting"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FollowUp" ADD CONSTRAINT "FollowUp_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PendingDecision" ADD CONSTRAINT "PendingDecision_relatedMeetingId_fkey" FOREIGN KEY ("relatedMeetingId") REFERENCES "Meeting"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PendingDecision" ADD CONSTRAINT "PendingDecision_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PendingDecision" ADD CONSTRAINT "PendingDecision_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Attachment" ADD CONSTRAINT "Attachment_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Activity" ADD CONSTRAINT "Activity_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 90f8706..2cbd195 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -18,11 +18,24 @@ model User { email String emailVerified Boolean @default(false) image String? + role String @default("PA") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt sessions Session[] accounts Account[] + meetingsCreated Meeting[] + contactsCreated Contact[] + tasksCreated Task[] + tasksAssigned Task[] @relation("TaskAssignee") + followUpsCreated FollowUp[] + followUpsAssigned FollowUp[] @relation("FollowUpAssignee") + decisionsCreated PendingDecision[] + decisionsMade PendingDecision[] @relation("PendingDecisionDecider") + attachmentsUploaded Attachment[] + notifications Notification[] + activitiesActed Activity[] @relation("ActivityActor") + @@unique([email]) @@map("user") } @@ -74,3 +87,162 @@ model Verification { @@index([identifier]) @@map("verification") } + +model Meeting { + id String @id + title String + startsAt DateTime + endsAt DateTime + location String? + notes String? + status String @default("scheduled") + createdById String + createdBy User @relation(fields: [createdById], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + attendees MeetingAttendee[] + tasks Task[] + followUps FollowUp[] + pendingDecisions PendingDecision[] + + @@index([startsAt]) +} + +model MeetingAttendee { + meetingId String + contactId String + roleLabel String? + meeting Meeting @relation(fields: [meetingId], references: [id], onDelete: Cascade) + contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + + @@id([meetingId, contactId]) +} + +model Contact { + id String @id + name String + company String? + email String? + phone String? + notes String? + createdById String + createdBy User @relation(fields: [createdById], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + meetings MeetingAttendee[] + tasks Task[] + followUps FollowUp[] + + @@index([name]) +} + +model Task { + id String @id + title String + description String? + dueAt DateTime? + priority String @default("medium") + status String @default("open") + assigneeId String + assignee User @relation("TaskAssignee", fields: [assigneeId], references: [id], onDelete: Restrict) + createdById String + createdBy User @relation(fields: [createdById], references: [id], onDelete: Restrict) + relatedContactId String? + relatedContact Contact? @relation(fields: [relatedContactId], references: [id], onDelete: SetNull) + relatedMeetingId String? + relatedMeeting Meeting? @relation(fields: [relatedMeetingId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + @@index([status, dueAt]) +} + +model FollowUp { + id String @id + title String + dueAt DateTime? + status String @default("pending") + assigneeId String + assignee User @relation("FollowUpAssignee", fields: [assigneeId], references: [id], onDelete: Restrict) + sourceContactId String? + sourceContact Contact? @relation(fields: [sourceContactId], references: [id], onDelete: SetNull) + sourceMeetingId String? + sourceMeeting Meeting? @relation(fields: [sourceMeetingId], references: [id], onDelete: SetNull) + createdById String + createdBy User @relation(fields: [createdById], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + @@index([dueAt]) +} + +model PendingDecision { + id String @id + title String + context String? + status String @default("pending") + dueAt DateTime? + relatedMeetingId String? + relatedMeeting Meeting? @relation(fields: [relatedMeetingId], references: [id], onDelete: SetNull) + decidedById String? + decidedBy User? @relation("PendingDecisionDecider", fields: [decidedById], references: [id], onDelete: SetNull) + decisionComment String? + decidedAt DateTime? + createdById String + createdBy User @relation(fields: [createdById], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + @@index([status, dueAt]) +} + +model Attachment { + id String @id + fileName String + mimeType String + size Int + storagePath String + parentType String + parentId String + uploadedById String + uploadedBy User @relation(fields: [uploadedById], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([parentType, parentId]) +} + +model Notification { + id String @id + userId String + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + type String + title String + body String? + relatedType String? + relatedId String? + readAt DateTime? + createdAt DateTime @default(now()) + + @@index([userId, readAt]) +} + +model Activity { + id String @id + entityType String + entityId String + action String + actorId String? + actor User? @relation("ActivityActor", fields: [actorId], references: [id], onDelete: Restrict) + payload Json? + createdAt DateTime @default(now()) + + @@index([entityType, entityId]) +} diff --git a/backend/prisma/seed.test.ts b/backend/prisma/seed.test.ts index c3ffc5e..6b816ce 100644 --- a/backend/prisma/seed.test.ts +++ b/backend/prisma/seed.test.ts @@ -12,27 +12,79 @@ vi.hoisted(() => { process.env.SEED_PASSWORD = "local-dev-password"; }); -type SeedRun = Awaited>; +type SeedUser = { id: string; email: string; name: string; role: string }; -async function runSeed(findUnique: ReturnType) { - const signUpEmail = vi.fn().mockResolvedValue({}); - const disconnect = vi.fn().mockResolvedValue(undefined); +function userStore() { + const users: SeedUser[] = []; + let next = 0; + const findFirst = vi.fn(async ({ where }: { where: { role: string } }) => { + return users.find((user) => user.role === where.role) ?? null; + }); + const findUnique = vi.fn(async ({ where }: { where: { email: string } }) => { + return users.find((user) => user.email === where.email) ?? null; + }); + const update = vi.fn( + async ({ + where, + data, + }: { + where: { id: string }; + data: Partial; + }) => { + const user = users.find((candidate) => candidate.id === where.id); + if (user) Object.assign(user, data); + return user; + }, + ); + const signUpEmail = vi.fn( + async ({ + body, + }: { + body: { email: string; name: string; password: string }; + }) => { + next += 1; + const user: SeedUser = { + id: `user-${next}`, + email: body.email, + name: body.name, + role: "PA", + }; + users.push(user); + return { token: `token-${next}`, user }; + }, + ); + const $disconnect = vi.fn(async () => {}); + return { users, findFirst, findUnique, update, signUpEmail, $disconnect }; +} + +type Store = ReturnType; +async function runSeed(store: Store) { vi.resetModules(); vi.doMock("../src/lib/prisma.js", () => ({ - prisma: { user: { findUnique }, $disconnect: disconnect }, + prisma: { + user: { + findFirst: store.findFirst, + findUnique: store.findUnique, + update: store.update, + }, + $disconnect: store.$disconnect, + }, + })); + vi.doMock("../src/lib/auth.js", () => ({ + auth: { api: { signUpEmail: store.signUpEmail } }, })); - vi.doMock("../src/lib/auth.js", () => ({ auth: { api: { signUpEmail } } })); const log = vi.spyOn(console, "log").mockImplementation(() => {}); const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const disconnects = store.$disconnect.mock.calls.length; await import("./seed.js"); + await vi.waitFor(() => + expect(store.$disconnect.mock.calls.length).toBe(disconnects + 1), + ); return { - findUnique, - signUpEmail, - disconnect, logged: () => log.mock.calls.map((call) => String(call[0])).join("\n"), errored: () => error.mock.calls.map((call) => String(call[0])).join("\n"), }; @@ -40,64 +92,122 @@ async function runSeed(findUnique: ReturnType) { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); vi.doUnmock("../src/lib/prisma.js"); vi.doUnmock("../src/lib/auth.js"); process.exitCode = undefined; }); describe("prisma/seed", () => { - it("skips accounts that already exist so running it twice is safe", async () => { - const findUnique = vi.fn().mockResolvedValue({ id: "user-1" }); + it("creates one PA and one CEO on an empty database", async () => { + const store = userStore(); - const run: SeedRun = await runSeed(findUnique); + const run = await runSeed(store); - await vi.waitFor(() => expect(findUnique).toHaveBeenCalledTimes(2)); - expect(run.signUpEmail).not.toHaveBeenCalled(); - await vi.waitFor(() => - expect(run.logged()).toContain("exists ceo@example.com"), - ); - expect(run.logged()).toContain("exists pa@example.com"); - await vi.waitFor(() => expect(run.disconnect).toHaveBeenCalled()); + expect(store.users.map((user) => user.role).sort()).toEqual(["CEO", "PA"]); + expect(store.update).toHaveBeenCalledWith({ + where: { id: "user-2" }, + data: { role: "CEO" }, + }); + expect(run.logged()).toContain("created PA pa@example.com"); + expect(run.logged()).toContain("created CEO ceo@example.com"); }); - it("creates both first accounts when the tables are empty", async () => { - const findUnique = vi.fn().mockResolvedValue(null); + it("stays idempotent so a second run creates nobody new", async () => { + const store = userStore(); + await runSeed(store); + const created = store.signUpEmail.mock.calls.length; - const run: SeedRun = await runSeed(findUnique); + const run = await runSeed(store); - await vi.waitFor(() => expect(run.signUpEmail).toHaveBeenCalledTimes(2)); - expect(run.signUpEmail.mock.calls[0]?.[0]).toEqual({ - body: { - email: "pa@example.com", - name: "Personal Assistant", - password: "local-dev-password", - }, + expect(store.signUpEmail.mock.calls.length).toBe(created); + expect(store.users).toHaveLength(2); + expect(store.users.map((user) => user.role).sort()).toEqual(["CEO", "PA"]); + expect(run.logged()).toContain("exists PA pa@example.com"); + expect(run.logged()).toContain("exists CEO ceo@example.com"); + }); + + it("repairs a user found by email that does not hold the role yet", async () => { + const store = userStore(); + store.users.push({ + id: "legacy-pa", + email: "pa@example.com", + name: "Legacy PA", + role: "PA", }); - expect(run.signUpEmail.mock.calls[1]?.[0]).toEqual({ - body: { - email: "ceo@example.com", - name: "Chief Executive", - password: "local-dev-password", - }, + store.users.push({ + id: "legacy-ceo", + email: "ceo@example.com", + name: "Legacy CEO", + role: "", }); - await vi.waitFor(() => - expect(run.logged()).toContain("created pa@example.com"), - ); - expect(run.logged()).toContain("created ceo@example.com"); + + const run = await runSeed(store); + + expect( + store.users.find((user) => user.email === "ceo@example.com")?.role, + ).toBe("CEO"); + expect(store.signUpEmail).not.toHaveBeenCalled(); + expect(run.logged()).toContain("repaired ceo@example.com role -> CEO"); + }); + + it("keeps looking by email when no row holds the role", async () => { + const store = userStore(); + store.users.push({ + id: "legacy-1", + email: "pa@example.com", + name: "Legacy", + role: "PA", + }); + store.users.push({ + id: "legacy-2", + email: "ceo@example.com", + name: "Legacy CEO", + role: "PA", + }); + + await runSeed(store); + + expect(store.users).toHaveLength(2); + expect(store.users.map((user) => user.role).sort()).toEqual(["CEO", "PA"]); + expect(store.signUpEmail).not.toHaveBeenCalled(); }); it("reports the failure and marks the run failed when the database is unreachable", async () => { - const findUnique = vi - .fn() - .mockRejectedValue(new Error("database unreachable")); + const store = userStore(); + store.findFirst.mockRejectedValue(new Error("database unreachable")); - const run: SeedRun = await runSeed(findUnique); + const run = await runSeed(store); await vi.waitFor(() => expect(run.errored()).toContain("database unreachable"), ); expect(process.exitCode).toBe(1); - expect(run.signUpEmail).not.toHaveBeenCalled(); - await vi.waitFor(() => expect(run.disconnect).toHaveBeenCalled()); + expect(store.signUpEmail).not.toHaveBeenCalled(); + }); + + it("leaves the PA role to the database default and only updates the CEO row", async () => { + const store = userStore(); + + await runSeed(store); + + expect(store.update).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledWith({ + where: { id: "user-2" }, + data: { role: "CEO" }, + }); + }); + + it("reads the account emails from the seed environment variables", async () => { + const store = userStore(); + vi.stubEnv("SEED_PA_EMAIL", "assistant@example.com"); + vi.stubEnv("SEED_CEO_EMAIL", "boss@example.com"); + + await runSeed(store); + + expect(store.users.map((user) => user.email).sort()).toEqual([ + "assistant@example.com", + "boss@example.com", + ]); }); }); diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index d068589..ada39d8 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -1,28 +1,54 @@ import { auth } from "../src/lib/auth.js"; +import { DEFAULT_USER_ROLE, type UserRole } from "../src/lib/constants.js"; import { prisma } from "../src/lib/prisma.js"; const paEmail = process.env["SEED_PA_EMAIL"] ?? "pa@example.com"; const ceoEmail = process.env["SEED_CEO_EMAIL"] ?? "ceo@example.com"; const password = process.env["SEED_PASSWORD"] ?? "local-dev-password"; -const people = [ - { email: paEmail, name: "Personal Assistant" }, - { email: ceoEmail, name: "Chief Executive" }, +const people: { role: UserRole; email: string; name: string }[] = [ + { role: "PA", email: paEmail, name: "Personal Assistant" }, + { role: "CEO", email: ceoEmail, name: "Chief Executive" }, ]; -async function seed() { - for (const person of people) { - const existing = await prisma.user.findUnique({ - where: { email: person.email }, - }); - if (existing) { - console.log(`exists ${person.email}`); - continue; +async function ensureUser(person: (typeof people)[number]) { + const byRole = await prisma.user.findFirst({ where: { role: person.role } }); + if (byRole) { + console.log(`exists ${person.role} ${byRole.email}`); + return; + } + + const byEmail = await prisma.user.findUnique({ + where: { email: person.email }, + }); + if (byEmail) { + if (byEmail.role !== person.role) { + await prisma.user.update({ + where: { id: byEmail.id }, + data: { role: person.role }, + }); + console.log(`repaired ${byEmail.email} role -> ${person.role}`); + } else { + console.log(`exists ${person.role} ${byEmail.email}`); } - await auth.api.signUpEmail({ - body: { email: person.email, name: person.name, password }, + return; + } + + const created = await auth.api.signUpEmail({ + body: { email: person.email, name: person.name, password }, + }); + if (person.role !== DEFAULT_USER_ROLE) { + await prisma.user.update({ + where: { id: created.user.id }, + data: { role: person.role }, }); - console.log(`created ${person.email}`); + } + console.log(`created ${person.role} ${person.email}`); +} + +async function seed() { + for (const person of people) { + await ensureUser(person); } console.log(`\nSign in as ${paEmail} or ${ceoEmail}`); diff --git a/backend/src/config/env.test.ts b/backend/src/config/env.test.ts index 8a06f17..f4e6c85 100644 --- a/backend/src/config/env.test.ts +++ b/backend/src/config/env.test.ts @@ -32,7 +32,7 @@ const loader = path.join( "loader.mjs", ); -const probe = `import(${JSON.stringify(envEntry)}).then((m) => console.log("PORT=" + m.env.PORT + " LOG_LEVEL=" + m.env.LOG_LEVEL + " NODE_ENV=" + m.env.NODE_ENV));`; +const probe = `import(${JSON.stringify(envEntry)}).then((m) => console.log("PORT=" + m.env.PORT + " LOG_LEVEL=" + m.env.LOG_LEVEL + " NODE_ENV=" + m.env.NODE_ENV + " TIMEZONE=" + m.env.TIMEZONE));`; function runEnvModule(cwd: string, envToUse: NodeJS.ProcessEnv) { return spawnSync( @@ -66,6 +66,7 @@ describe("config/env", () => { delete cleanEnv.PORT; delete cleanEnv.LOG_LEVEL; delete cleanEnv.NODE_ENV; + delete cleanEnv.TIMEZONE; const emptyDir = mkdtempSync(path.join(tmpdir(), "env-defaults-")); try { @@ -74,12 +75,42 @@ describe("config/env", () => { expect(result.stdout).toContain("PORT=4000"); expect(result.stdout).toContain("LOG_LEVEL=info"); expect(result.stdout).toContain("NODE_ENV=development"); + expect(result.stdout).toContain("TIMEZONE=UTC"); } finally { rmSync(emptyDir, { recursive: true, force: true }); } }, ); + it( + "reads TIMEZONE as the IANA zone it was given", + { timeout: 60_000 }, + () => { + const result = runEnvModule(backendRoot, { + ...process.env, + TIMEZONE: "Africa/Lagos", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("TIMEZONE=Africa/Lagos"); + }, + ); + + it( + "refuses a TIMEZONE that is not a real IANA zone", + { timeout: 60_000 }, + () => { + const result = runEnvModule(backendRoot, { + ...process.env, + TIMEZONE: "Not/AZone", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Invalid environment variables:"); + expect(result.stderr).toContain("TIMEZONE"); + }, + ); + it( "exits with status 1 and names the variable when a required variable is missing", { timeout: 60_000 }, diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index dc80df3..1f8beb1 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -1,6 +1,15 @@ import "dotenv/config"; import { z } from "zod"; +function isTimezone(value: string): boolean { + try { + new Intl.DateTimeFormat("en-US", { timeZone: value }); + return true; + } catch { + return false; + } +} + const schema = z.object({ NODE_ENV: z .enum(["development", "test", "production"]) @@ -10,6 +19,9 @@ const schema = z.object({ BETTER_AUTH_SECRET: z.string().min(32), BETTER_AUTH_URL: z.url(), LOG_LEVEL: z.string().default("info"), + TIMEZONE: z.string().default("UTC").refine(isTimezone, { + message: "must be a valid IANA time zone, for example Africa/Lagos", + }), }); const result = schema.safeParse(process.env); diff --git a/backend/src/lib/auth.test.ts b/backend/src/lib/auth.test.ts index 76cde5d..de9256d 100644 --- a/backend/src/lib/auth.test.ts +++ b/backend/src/lib/auth.test.ts @@ -11,18 +11,21 @@ vi.hoisted(() => { import { auth } from "./auth.js"; -describe("lib/auth", () => { - it("turns on the email and password sign in the seed accounts rely on", () => { - expect(auth.options.emailAndPassword?.enabled).toBe(true); +describe("lib/auth role configuration (AC-4)", () => { + it("keeps role out of sign up input so a request cannot grant itself CEO", () => { + const role = auth.options.user?.additionalFields?.role; + + expect(role?.input).toBe(false); }); - it("wires accounts through the prisma adapter rather than an in memory store", () => { - expect(typeof auth.options.database).toBe("function"); + it("types role as a string and defaults a new user to PA", () => { + const role = auth.options.user?.additionalFields?.role; + + expect(role?.type).toBe("string"); + expect(role?.defaultValue).toBe("PA"); }); - it("exposes the sign in and sign up handlers used by the routes and the seed", () => { - expect(typeof auth.api.signInEmail).toBe("function"); - expect(typeof auth.api.signUpEmail).toBe("function"); - expect(typeof auth.api.getSession).toBe("function"); + it("leaves email and password sign up enabled, which the seed depends on", () => { + expect(auth.options.emailAndPassword?.enabled).toBe(true); }); }); diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index 7a2f331..78b1b89 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -1,5 +1,6 @@ import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; +import { DEFAULT_USER_ROLE } from "./constants.js"; import { prisma } from "./prisma.js"; export const auth = betterAuth({ @@ -7,6 +8,15 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, }, + user: { + additionalFields: { + role: { + type: "string", + defaultValue: DEFAULT_USER_ROLE, + input: false, + }, + }, + }, }); export type Session = typeof auth.$Infer.Session; diff --git a/backend/src/lib/constants.test.ts b/backend/src/lib/constants.test.ts new file mode 100644 index 0000000..25bf48f --- /dev/null +++ b/backend/src/lib/constants.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { + ACTIVITY_ACTIONS, + ACTIVITY_ENTITY_TYPES, + DEFAULT_FOLLOW_UP_STATUS, + DEFAULT_MEETING_STATUS, + DEFAULT_PENDING_DECISION_STATUS, + DEFAULT_TASK_PRIORITY, + DEFAULT_TASK_STATUS, + DEFAULT_USER_ROLE, + FOLLOW_UP_STATUSES, + InvalidTokenError, + MEETING_STATUSES, + PENDING_DECISION_STATUSES, + TASK_PRIORITIES, + TASK_STATUSES, + USER_ROLES, + token, + validateEntityTokens, +} from "./constants.js"; + +describe("lib/constants", () => { + it("keeps every default inside its own closed set", () => { + expect(USER_ROLES).toContain(DEFAULT_USER_ROLE); + expect(MEETING_STATUSES).toContain(DEFAULT_MEETING_STATUS); + expect(TASK_STATUSES).toContain(DEFAULT_TASK_STATUS); + expect(TASK_PRIORITIES).toContain(DEFAULT_TASK_PRIORITY); + expect(FOLLOW_UP_STATUSES).toContain(DEFAULT_FOLLOW_UP_STATUS); + expect(PENDING_DECISION_STATUSES).toContain( + DEFAULT_PENDING_DECISION_STATUS, + ); + }); + + it("returns the value when it is inside the set", () => { + expect(token(TASK_STATUSES, "in_progress", "status")).toBe("in_progress"); + }); + + it("names the field and the allowed values when a token is rejected", () => { + expect(() => token(TASK_STATUSES, "archived", "status")).toThrow( + InvalidTokenError, + ); + expect(() => token(TASK_STATUSES, "archived", "status")).toThrow( + "status must be one of open, in_progress, done, cancelled", + ); + }); + + it("rejects a value that is not a string at all", () => { + expect(() => token(TASK_STATUSES, 7, "status")).toThrow(InvalidTokenError); + }); + + it("validates the closed set columns on a task payload", () => { + expect(() => + validateEntityTokens("task", { status: "open", priority: "urgent" }), + ).not.toThrow(); + expect(() => validateEntityTokens("task", { status: "archived" })).toThrow( + InvalidTokenError, + ); + expect(() => validateEntityTokens("task", { priority: "asap" })).toThrow( + InvalidTokenError, + ); + }); + + it("leaves fields that are not closed sets alone", () => { + expect(() => + validateEntityTokens("contact", { name: "Ada", company: "Analytical" }), + ).not.toThrow(); + }); + + it("carries the tokens the data model spec names", () => { + expect(ACTIVITY_ENTITY_TYPES).toEqual([ + "meeting", + "contact", + "task", + "follow_up", + "pending_decision", + "attachment", + "notification", + "user", + ]); + expect(Object.values(ACTIVITY_ACTIONS)).toEqual([ + "created", + "updated", + "deleted", + "restored", + ]); + }); + + it("rejects an empty, missing, or wrongly cased token", () => { + expect(() => token(TASK_STATUSES, "", "status")).toThrow(InvalidTokenError); + expect(() => token(TASK_STATUSES, undefined, "status")).toThrow( + InvalidTokenError, + ); + expect(() => token(TASK_STATUSES, "Open", "status")).toThrow( + InvalidTokenError, + ); + }); + + it("names the rejected value in the message so a bad write is easy to trace", () => { + expect(() => token(TASK_STATUSES, "archived", "status")).toThrow( + 'received "archived"', + ); + expect(() => token(TASK_STATUSES, 7, "status")).toThrow("received 7"); + }); + + it("validates every entity that carries a closed set column", () => { + expect(() => + validateEntityTokens("meeting", { status: "cancelled" }), + ).not.toThrow(); + expect(() => validateEntityTokens("meeting", { status: "draft" })).toThrow( + InvalidTokenError, + ); + expect(() => + validateEntityTokens("follow_up", { status: "dismissed" }), + ).not.toThrow(); + expect(() => + validateEntityTokens("pending_decision", { status: "approved" }), + ).not.toThrow(); + expect(() => + validateEntityTokens("pending_decision", { status: "maybe" }), + ).toThrow(InvalidTokenError); + expect(() => + validateEntityTokens("attachment", { parentType: "follow_up" }), + ).not.toThrow(); + expect(() => + validateEntityTokens("attachment", { parentType: "invoice" }), + ).toThrow(InvalidTokenError); + }); + + it("skips a closed set column the caller did not send", () => { + expect(() => + validateEntityTokens("task", { priority: "high" }), + ).not.toThrow(); + expect(() => + validateEntityTokens("meeting", { title: "Board sync" }), + ).not.toThrow(); + }); + + it("returns early for entity types that have no closed set columns", () => { + expect(() => + validateEntityTokens("contact", { + name: "Ada", + email: "ada@example.com", + }), + ).not.toThrow(); + expect(() => + validateEntityTokens("notification", { title: "Task due", readAt: null }), + ).not.toThrow(); + expect(() => validateEntityTokens("user", { role: "CEO" })).not.toThrow(); + }); +}); diff --git a/backend/src/lib/constants.ts b/backend/src/lib/constants.ts new file mode 100644 index 0000000..61fca74 --- /dev/null +++ b/backend/src/lib/constants.ts @@ -0,0 +1,107 @@ +export const USER_ROLES = ["PA", "CEO"] as const; +export type UserRole = (typeof USER_ROLES)[number]; +export const DEFAULT_USER_ROLE: UserRole = "PA"; + +export const MEETING_STATUSES = ["scheduled", "cancelled"] as const; +export type MeetingStatus = (typeof MEETING_STATUSES)[number]; +export const DEFAULT_MEETING_STATUS: MeetingStatus = "scheduled"; + +export const TASK_STATUSES = [ + "open", + "in_progress", + "done", + "cancelled", +] as const; +export type TaskStatus = (typeof TASK_STATUSES)[number]; +export const DEFAULT_TASK_STATUS: TaskStatus = "open"; + +export const TASK_PRIORITIES = ["low", "medium", "high", "urgent"] as const; +export type TaskPriority = (typeof TASK_PRIORITIES)[number]; +export const DEFAULT_TASK_PRIORITY: TaskPriority = "medium"; + +export const FOLLOW_UP_STATUSES = ["pending", "done", "dismissed"] as const; +export type FollowUpStatus = (typeof FOLLOW_UP_STATUSES)[number]; +export const DEFAULT_FOLLOW_UP_STATUS: FollowUpStatus = "pending"; + +export const PENDING_DECISION_STATUSES = [ + "pending", + "approved", + "declined", +] as const; +export type PendingDecisionStatus = (typeof PENDING_DECISION_STATUSES)[number]; +export const DEFAULT_PENDING_DECISION_STATUS: PendingDecisionStatus = "pending"; + +export const ACTIVITY_ACTIONS = { + created: "created", + updated: "updated", + deleted: "deleted", + restored: "restored", +} as const; +export type ActivityAction = + (typeof ACTIVITY_ACTIONS)[keyof typeof ACTIVITY_ACTIONS]; + +export const ACTIVITY_ENTITY_TYPES = [ + "meeting", + "contact", + "task", + "follow_up", + "pending_decision", + "attachment", + "notification", + "user", +] as const; +export type ActivityEntityType = (typeof ACTIVITY_ENTITY_TYPES)[number]; + +export const ATTACHMENT_PARENT_TYPES = [ + "meeting", + "task", + "contact", + "follow_up", +] as const; +export type AttachmentParentType = (typeof ATTACHMENT_PARENT_TYPES)[number]; + +export const ATTENDEE_ROLE_LABELS = ["required", "optional"] as const; +export type AttendeeRoleLabel = (typeof ATTENDEE_ROLE_LABELS)[number]; + +export const ENTITY_TOKENS: Partial< + Record> +> = { + meeting: { status: MEETING_STATUSES }, + task: { status: TASK_STATUSES, priority: TASK_PRIORITIES }, + follow_up: { status: FOLLOW_UP_STATUSES }, + pending_decision: { status: PENDING_DECISION_STATUSES }, + attachment: { parentType: ATTACHMENT_PARENT_TYPES }, +}; + +export class InvalidTokenError extends Error { + constructor(field: string, allowed: readonly string[], received: unknown) { + super( + `${field} must be one of ${allowed.join(", ")}; received ${JSON.stringify(received)}`, + ); + this.name = "InvalidTokenError"; + } +} + +export function token( + allowed: readonly T[], + value: unknown, + field: string, +): T { + if (typeof value !== "string" || !allowed.includes(value as T)) { + throw new InvalidTokenError(field, allowed, value); + } + return value as T; +} + +export function validateEntityTokens( + entityType: ActivityEntityType, + data: Record, +): void { + const fields = ENTITY_TOKENS[entityType]; + if (!fields) return; + for (const [field, allowed] of Object.entries(fields)) { + if (data[field] !== undefined) { + token(allowed, data[field], field); + } + } +} diff --git a/backend/src/lib/prisma.ts b/backend/src/lib/prisma.ts index eb9115c..73a2484 100644 --- a/backend/src/lib/prisma.ts +++ b/backend/src/lib/prisma.ts @@ -1,3 +1,4 @@ +// Query conventions (spec 0002): default reads filter deletedAt IS NULL, relation reads filter soft deleted parents, closed set columns validate against ./constants.js, ids come from ./repository.js. import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaClient } from "../generated/prisma/client.js"; import { env } from "../config/env.js"; diff --git a/backend/src/lib/repository.test.ts b/backend/src/lib/repository.test.ts new file mode 100644 index 0000000..6dde0d1 --- /dev/null +++ b/backend/src/lib/repository.test.ts @@ -0,0 +1,225 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { InvalidTokenError } from "./constants.js"; + +const mocks = vi.hoisted(() => ({ + activityCreate: vi.fn(), +})); + +vi.mock("./prisma.js", () => ({ + prisma: { activity: { create: mocks.activityCreate } }, +})); + +import { createRow, updateRow } from "./repository.js"; + +const uuidV4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +beforeEach(() => { + mocks.activityCreate.mockReset().mockResolvedValue({}); +}); + +describe("lib/repository createRow", () => { + it("assigns a UUID v4 primary key and never lets the caller supply one", async () => { + const create = vi.fn(async (data: { id: string; title: string }) => data); + + const row = await createRow({ + entityType: "meeting", + actorId: "user-1", + data: { title: "Board sync" }, + create, + }); + + expect(row.id).toMatch(uuidV4); + expect(create).toHaveBeenCalledWith({ + id: expect.stringMatching(uuidV4), + title: "Board sync", + }); + }); + + it("appends a created Activity row with the actor and the new row as payload", async () => { + const create = vi.fn(async (data: { id: string; title: string }) => data); + + const row = await createRow({ + entityType: "meeting", + actorId: "user-1", + data: { title: "Board sync" }, + create, + }); + + expect(mocks.activityCreate).toHaveBeenCalledWith({ + data: { + id: expect.stringMatching(uuidV4), + entityType: "meeting", + entityId: row.id, + action: "created", + actorId: "user-1", + payload: { after: row }, + }, + }); + }); + + it("rejects a status outside the constants module before anything is written", async () => { + const create = vi.fn(async (data: { id: string }) => data); + + await expect( + createRow({ + entityType: "task", + actorId: "user-1", + data: { title: "Send deck", status: "archived" }, + create, + }), + ).rejects.toThrow(InvalidTokenError); + expect(create).not.toHaveBeenCalled(); + expect(mocks.activityCreate).not.toHaveBeenCalled(); + }); + + it("rejects a priority outside the constants module", async () => { + const create = vi.fn(async (data: { id: string }) => data); + + await expect( + createRow({ + entityType: "task", + actorId: "user-1", + data: { title: "Send deck", priority: "asap" }, + create, + }), + ).rejects.toThrow(InvalidTokenError); + }); +}); + +describe("lib/repository updateRow", () => { + it("logs only the keys that actually changed", async () => { + const update = vi.fn(async (data: { status: string }) => data); + + await updateRow({ + entityType: "task", + entityId: "task-1", + actorId: "user-1", + current: { status: "open", priority: "medium", title: "Send deck" }, + data: { status: "done" }, + update, + }); + + expect(mocks.activityCreate).toHaveBeenCalledWith({ + data: { + id: expect.stringMatching(uuidV4), + entityType: "task", + entityId: "task-1", + action: "updated", + actorId: "user-1", + payload: { before: { status: "open" }, after: { status: "done" } }, + }, + }); + }); + + it("writes no Activity row when nothing changed", async () => { + const update = vi.fn(async (data: { status: string }) => data); + + await updateRow({ + entityType: "task", + entityId: "task-1", + actorId: "user-1", + current: { status: "open" }, + data: { status: "open" }, + update, + }); + + expect(update).toHaveBeenCalledWith({ status: "open" }); + expect(mocks.activityCreate).not.toHaveBeenCalled(); + }); + + it("rejects an invalid status before the update runs", async () => { + const update = vi.fn(async (data: { status: string }) => data); + + await expect( + updateRow({ + entityType: "task", + entityId: "task-1", + actorId: "user-1", + current: { status: "open" }, + data: { status: "archived" }, + update, + }), + ).rejects.toThrow(InvalidTokenError); + expect(update).not.toHaveBeenCalled(); + }); +}); + +describe("lib/repository other closed set columns", () => { + it("rejects an attachment parent type outside the constants module before anything is written", async () => { + const create = vi.fn(async (data: { id: string }) => data); + + await expect( + createRow({ + entityType: "attachment", + actorId: "user-1", + data: { parentType: "invoice", fileName: "deck.pdf" }, + create, + }), + ).rejects.toThrow(InvalidTokenError); + expect(create).not.toHaveBeenCalled(); + expect(mocks.activityCreate).not.toHaveBeenCalled(); + }); + + it("rejects a follow up status outside the constants module", async () => { + const create = vi.fn(async (data: { id: string }) => data); + + await expect( + createRow({ + entityType: "follow_up", + actorId: "user-1", + data: { title: "Call the bank", status: "someday" }, + create, + }), + ).rejects.toThrow(InvalidTokenError); + }); + + it("logs only the keys that changed out of a multi key patch", async () => { + const update = vi.fn( + async (data: { status: string; title: string }) => data, + ); + + await updateRow({ + entityType: "task", + entityId: "task-1", + actorId: "user-1", + current: { status: "open", title: "Send deck" }, + data: { status: "done", title: "Send deck" }, + update, + }); + + expect(mocks.activityCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + payload: { before: { status: "open" }, after: { status: "done" } }, + }), + }); + }); + + it("stores the activity payload as JSON so a date survives the write", async () => { + const create = vi.fn( + async (data: { id: string; title: string; startsAt: Date }) => data, + ); + + const row = await createRow({ + entityType: "meeting", + actorId: "user-1", + data: { + title: "Board sync", + startsAt: new Date("2026-10-10T23:30:00.000Z"), + }, + create, + }); + + expect(mocks.activityCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + payload: { + after: { + id: row.id, + title: "Board sync", + startsAt: "2026-10-10T23:30:00.000Z", + }, + }, + }), + }); + }); +}); diff --git a/backend/src/lib/repository.ts b/backend/src/lib/repository.ts new file mode 100644 index 0000000..50d7fad --- /dev/null +++ b/backend/src/lib/repository.ts @@ -0,0 +1,92 @@ +import { randomUUID } from "node:crypto"; +import type { Prisma } from "../generated/prisma/client.js"; +import { + ACTIVITY_ACTIONS, + validateEntityTokens, + type ActivityAction, + type ActivityEntityType, +} from "./constants.js"; +import { prisma } from "./prisma.js"; + +type CreateRowArgs = { + entityType: ActivityEntityType; + actorId: string; + data: TData; + create: (data: TData & { id: string }) => Promise; +}; + +type UpdateRowArgs = { + entityType: ActivityEntityType; + entityId: string; + actorId: string; + current: Record; + data: TData; + update: (data: TData) => Promise; +}; + +type ActivityInput = { + entityType: ActivityEntityType; + entityId: string; + action: ActivityAction; + actorId: string | null; + payload: Prisma.InputJsonValue; +}; + +function audit(value: unknown): Prisma.InputJsonValue { + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +} + +async function appendActivity(input: ActivityInput): Promise { + await prisma.activity.create({ + data: { + id: randomUUID(), + entityType: input.entityType, + entityId: input.entityId, + action: input.action, + actorId: input.actorId, + payload: input.payload, + }, + }); +} + +export async function createRow< + TData extends object, + TResult extends { id: string }, +>(args: CreateRowArgs): Promise { + validateEntityTokens(args.entityType, args.data as Record); + const row = await args.create({ ...args.data, id: randomUUID() }); + await appendActivity({ + entityType: args.entityType, + entityId: row.id, + action: ACTIVITY_ACTIONS.created, + actorId: args.actorId, + payload: { after: audit(row) }, + }); + return row; +} + +export async function updateRow( + args: UpdateRowArgs, +): Promise { + validateEntityTokens(args.entityType, args.data as Record); + const patch = args.data as Record; + const before: Record = {}; + const after: Record = {}; + for (const key of Object.keys(patch)) { + if (args.current[key] !== patch[key]) { + before[key] = args.current[key]; + after[key] = patch[key]; + } + } + const row = await args.update(args.data); + if (Object.keys(after).length > 0) { + await appendActivity({ + entityType: args.entityType, + entityId: args.entityId, + action: ACTIVITY_ACTIONS.updated, + actorId: args.actorId, + payload: { before: audit(before), after: audit(after) }, + }); + } + return row; +} diff --git a/backend/src/lib/schema.test.ts b/backend/src/lib/schema.test.ts new file mode 100644 index 0000000..a91b580 --- /dev/null +++ b/backend/src/lib/schema.test.ts @@ -0,0 +1,435 @@ +import { execFileSync } from "node:child_process"; +import dns from "node:dns"; +import net from "node:net"; +import { fileURLToPath } from "node:url"; +import { afterAll, describe, expect, it, vi } from "vitest"; +import pg from "pg"; + +dns.setDefaultResultOrder("ipv4first"); +net.setDefaultAutoSelectFamily(false); + +vi.hoisted(() => { + if (typeof process.loadEnvFile === "function") { + try { + process.loadEnvFile(); + } catch { + /* no local .env to load */ + } + } + process.env.NODE_ENV ??= "test"; + process.env.PORT ??= "4000"; + process.env.LOG_LEVEL ??= "silent"; + process.env.DATABASE_URL ??= "postgresql://user:password@localhost:5432/test"; + process.env.BETTER_AUTH_SECRET ??= "test-secret-test-secret-test-secret-1234"; + process.env.BETTER_AUTH_URL ??= "http://localhost:3000"; + process.env.TIMEZONE ??= "UTC"; +}); + +const backendRoot = fileURLToPath(new URL("../../", import.meta.url)); +const baseUnpooled = process.env["DATABASE_URL_UNPOOLED"]; +const scratchName = `data_model_test_${process.pid}`; +const uuidV4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const connectionError = + /ETIMEDOUT|ENETUNREACH|ECONNREFUSED|ECONNRESET|ETIMEOUT|getaddrinfo|socket hang up/i; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function connect(connectionString: string) { + let last: unknown; + for (let attempt = 0; attempt < 8; attempt += 1) { + const client = new pg.Client({ + connectionString, + connectionTimeoutMillis: 12_000, + }); + try { + await client.connect(); + return client; + } catch (error) { + last = error; + await client.end().catch(() => {}); + await sleep(1200); + } + } + throw last; +} + +function scratchUrls() { + const url = new URL(baseUnpooled as string); + url.pathname = `/${scratchName}`; + const value = url.toString(); + return { databaseUrl: value, unpooledUrl: value }; +} + +function run(command: string, args: string[], env: NodeJS.ProcessEnv) { + execFileSync(command, args, { + cwd: backendRoot, + env, + stdio: "pipe", + timeout: 90_000, + }); +} + +async function runWithRetry( + command: string, + args: string[], + env: NodeJS.ProcessEnv, + attempts = 4, +) { + let last: unknown; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + run(command, args, env); + return; + } catch (error) { + last = error; + await sleep(1000); + } + } + throw last; +} + +async function dropScratch(admin: pg.Client) { + for (let attempt = 0; attempt < 8; attempt += 1) { + try { + await admin.query(`DROP DATABASE IF EXISTS "${scratchName}"`); + return; + } catch { + await sleep(600); + } + } + await admin.query(`DROP DATABASE IF EXISTS "${scratchName}"`); +} + +type RepositoryModule = typeof import("./repository.js"); +type PrismaModule = typeof import("./prisma.js"); + +let admin: pg.Client | undefined; +let repository: RepositoryModule | undefined; +let client: PrismaModule["prisma"] | undefined; +let skipReason: string | undefined; + +if (!baseUnpooled) { + skipReason = + "DATABASE_URL_UNPOOLED is not set, so there is no database to use"; +} else { + try { + const { databaseUrl, unpooledUrl } = scratchUrls(); + admin = await connect(baseUnpooled); + await dropScratch(admin); + await admin.query(`CREATE DATABASE "${scratchName}"`); + + const childEnv: NodeJS.ProcessEnv = { + ...process.env, + DATABASE_URL: databaseUrl, + DATABASE_URL_UNPOOLED: unpooledUrl, + SEED_PA_EMAIL: "pa@example.com", + SEED_CEO_EMAIL: "ceo@example.com", + SEED_PASSWORD: "local-dev-password", + NODE_OPTIONS: "--dns-result-order=ipv4first", + }; + await runWithRetry("npx", ["prisma", "migrate", "deploy"], childEnv); + await runWithRetry("npx", ["tsx", "prisma/seed.ts"], childEnv); + await runWithRetry("npx", ["tsx", "prisma/seed.ts"], childEnv); + + vi.resetModules(); + process.env.DATABASE_URL = databaseUrl; + repository = await import("./repository.js"); + ({ prisma: client } = await import("./prisma.js")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!connectionError.test(`${message} ${String(error)}`)) { + throw error; + } + skipReason = `database unreachable, skipped: ${message.slice(0, 160)}`; + if (admin) { + await dropScratch(admin).catch(() => {}); + await admin.end().catch(() => {}); + } + } +} + +if (skipReason) { + console.warn(`[schema.test] ${skipReason}`); +} + +describe.runIf(!skipReason)("data model on a fresh database", () => { + afterAll(async () => { + await client?.$disconnect().catch(() => {}); + if (admin) { + await dropScratch(admin).catch(() => {}); + await admin.end().catch(() => {}); + } + }, 120_000); + + it( + "creates every domain table the data model names", + { timeout: 60_000 }, + async () => { + const result = await admin!.query( + "select table_name from information_schema.tables where table_schema = 'public' and table_type = 'BASE TABLE'", + ); + const tables = result.rows.map((row) => row.table_name); + for (const table of [ + "Meeting", + "MeetingAttendee", + "Contact", + "Task", + "FollowUp", + "PendingDecision", + "Attachment", + "Notification", + "Activity", + ]) { + expect(tables).toContain(table); + } + }, + ); + + it( + "adds a PA defaulted role column that only accepts PA or CEO", + { timeout: 60_000 }, + async () => { + const column = await admin!.query( + "select is_nullable, column_default from information_schema.columns where table_name = 'user' and column_name = 'role'", + ); + expect(column.rows[0]?.is_nullable).toBe("NO"); + expect(String(column.rows[0]?.column_default)).toContain("PA"); + + const check = await admin!.query( + "select pg_get_constraintdef(oid) as def from pg_constraint where conrelid = '\"user\"'::regclass and contype = 'c'", + ); + expect(check.rows).toHaveLength(1); + expect(check.rows[0]?.def).toContain("PA"); + expect(check.rows[0]?.def).toContain("CEO"); + }, + ); + + it( + "stores naive wall clock times and gives no id a database default", + { timeout: 60_000 }, + async () => { + const timestamps = await admin!.query( + "select data_type from information_schema.columns where table_name = 'Meeting' and column_name in ('startsAt', 'endsAt')", + ); + expect(timestamps.rows).toHaveLength(2); + for (const row of timestamps.rows) { + expect(row.data_type).toBe("timestamp without time zone"); + } + + const defaults = await admin!.query( + "select table_name from information_schema.columns where table_schema = 'public' and column_name = 'id' and column_default is null and table_name in ('Meeting', 'Contact', 'Task', 'FollowUp', 'PendingDecision', 'Attachment', 'Notification', 'Activity')", + ); + expect(defaults.rows).toHaveLength(8); + + const attendeeKey = await admin!.query( + "select column_name from information_schema.columns where table_name = 'MeetingAttendee' and column_name = 'id'", + ); + expect(attendeeKey.rows).toHaveLength(0); + }, + ); + + it( + "keeps the indexes the dashboard and list queries need", + { timeout: 60_000 }, + async () => { + const indexes = await admin!.query( + "select tablename, indexname from pg_indexes where schemaname = 'public' and tablename in ('Meeting', 'Task', 'FollowUp', 'PendingDecision', 'Contact', 'Notification', 'Attachment', 'Activity')", + ); + const found = new Set( + indexes.rows.map((row) => `${row.tablename}.${row.indexname}`), + ); + + for (const index of [ + "Meeting.Meeting_startsAt_idx", + "Task.Task_status_dueAt_idx", + "FollowUp.FollowUp_dueAt_idx", + "PendingDecision.PendingDecision_status_dueAt_idx", + "Contact.Contact_name_idx", + "Notification.Notification_userId_readAt_idx", + "Attachment.Attachment_parentType_parentId_idx", + "Activity.Activity_entityType_entityId_idx", + ]) { + expect(found).toContain(index); + } + }, + ); + + it( + "freezes the foreign key delete rules the spec decided", + { timeout: 60_000 }, + async () => { + const rules = await admin!.query( + "select conname, confdeltype from pg_constraint where contype = 'f'", + ); + const byName = new Map( + rules.rows.map((row) => [ + row.conname as string, + row.confdeltype as string, + ]), + ); + + expect(byName.get("Meeting_createdById_fkey")).toBe("r"); + expect(byName.get("Task_assigneeId_fkey")).toBe("r"); + expect(byName.get("Activity_actorId_fkey")).toBe("r"); + expect(byName.get("MeetingAttendee_meetingId_fkey")).toBe("c"); + expect(byName.get("MeetingAttendee_contactId_fkey")).toBe("c"); + expect(byName.get("Task_relatedMeetingId_fkey")).toBe("n"); + expect(byName.get("PendingDecision_decidedById_fkey")).toBe("n"); + }, + ); + + it( + "gives MeetingAttendee a composite key and no surrogate id", + { timeout: 60_000 }, + async () => { + const key = await admin!.query( + "select pg_get_constraintdef(oid) as def from pg_constraint where conrelid = '\"MeetingAttendee\"'::regclass and contype = 'p'", + ); + expect(key.rows[0]?.def).toBe('PRIMARY KEY ("meetingId", "contactId")'); + }, + ); + + it( + "leaves exactly one user per role after seeding the same database twice", + { timeout: 60_000 }, + async () => { + const users = await client!.user.findMany({ orderBy: { role: "asc" } }); + + expect(users.map((user) => user.role)).toEqual(["CEO", "PA"]); + expect(users.map((user) => user.email).sort()).toEqual([ + "ceo@example.com", + "pa@example.com", + ]); + }, + ); + + it( + "inserts domain rows with a UUID v4 from the repository and logs the activity", + { timeout: 60_000 }, + async () => { + const pa = await client!.user.findFirstOrThrow({ where: { role: "PA" } }); + + const meeting = await repository!.createRow({ + entityType: "meeting", + actorId: pa.id, + data: { + title: "Board sync", + startsAt: new Date("2026-10-12T09:00:00.000Z"), + endsAt: new Date("2026-10-12T10:00:00.000Z"), + status: "scheduled", + createdById: pa.id, + }, + create: (data) => client!.meeting.create({ data }), + }); + + expect(meeting.id).toMatch(uuidV4); + + const activity = await client!.activity.findFirstOrThrow({ + where: { entityId: meeting.id }, + }); + expect(activity.entityType).toBe("meeting"); + expect(activity.action).toBe("created"); + expect(activity.actorId).toBe(pa.id); + }, + ); + + it( + "round trips every field of a meeting with two attendees", + { timeout: 60_000 }, + async () => { + const pa = await client!.user.findFirstOrThrow({ where: { role: "PA" } }); + const ada = await repository!.createRow({ + entityType: "contact", + actorId: pa.id, + data: { name: "Ada Lovelace", createdById: pa.id }, + create: (data) => client!.contact.create({ data }), + }); + const grace = await repository!.createRow({ + entityType: "contact", + actorId: pa.id, + data: { name: "Grace Hopper", createdById: pa.id }, + create: (data) => client!.contact.create({ data }), + }); + const meeting = await repository!.createRow({ + entityType: "meeting", + actorId: pa.id, + data: { + title: "Board sync", + startsAt: new Date("2026-10-12T09:00:00.000Z"), + endsAt: new Date("2026-10-12T10:00:00.000Z"), + status: "scheduled", + createdById: pa.id, + }, + create: (data) => client!.meeting.create({ data }), + }); + + await client!.meetingAttendee.createMany({ + data: [ + { meetingId: meeting.id, contactId: ada.id, roleLabel: "required" }, + { meetingId: meeting.id, contactId: grace.id, roleLabel: "optional" }, + ], + }); + + const loaded = await client!.meeting.findUniqueOrThrow({ + where: { id: meeting.id }, + include: { + attendees: true, + createdBy: { select: { id: true } }, + }, + }); + + expect(loaded.title).toBe("Board sync"); + expect(loaded.location).toBeNull(); + expect(loaded.notes).toBeNull(); + expect(loaded.deletedAt).toBeNull(); + expect(loaded.startsAt.toISOString()).toBe("2026-10-12T09:00:00.000Z"); + expect(loaded.createdBy.id).toBe(pa.id); + expect(loaded.attendees).toHaveLength(2); + expect( + new Map( + loaded.attendees.map((attendee) => [ + attendee.contactId, + attendee.roleLabel, + ]), + ), + ).toEqual( + new Map([ + [ada.id, "required"], + [grace.id, "optional"], + ]), + ); + }, + ); + + it( + "refuses a status that is not in the constants module before the write", + { timeout: 60_000 }, + async () => { + const pa = await client!.user.findFirstOrThrow({ where: { role: "PA" } }); + + await expect( + repository!.createRow({ + entityType: "task", + actorId: pa.id, + data: { + title: "Send deck", + status: "archived", + priority: "medium", + assigneeId: pa.id, + createdById: pa.id, + }, + create: (data) => client!.task.create({ data }), + }), + ).rejects.toThrow(/status must be one of/); + + const archived = await client!.task.count({ + where: { title: "Send deck" }, + }); + expect(archived).toBe(0); + }, + ); +}); diff --git a/backend/src/lib/timezone.test.ts b/backend/src/lib/timezone.test.ts new file mode 100644 index 0000000..223e299 --- /dev/null +++ b/backend/src/lib/timezone.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { env } from "../config/env.js"; + +vi.hoisted(() => { + process.env.NODE_ENV = "test"; + process.env.PORT = "4000"; + process.env.LOG_LEVEL = "silent"; + process.env.DATABASE_URL = "postgresql://user:password@localhost:5432/test"; + process.env.BETTER_AUTH_SECRET = "test-secret-test-secret-test-secret-1234"; + process.env.BETTER_AUTH_URL = "http://localhost:3000"; +}); + +async function helperFor(timezone: string) { + vi.resetModules(); + vi.stubEnv("TIMEZONE", timezone); + const helper = await import("./timezone.js"); + vi.unstubAllEnvs(); + return helper; +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("lib/timezone", () => { + it("falls back to UTC when TIMEZONE is unset", () => { + expect(env.TIMEZONE).toBe("UTC"); + }); + + it("returns the local midnight and last millisecond of the instant's day", async () => { + const { startOfDay, endOfDay } = await helperFor("UTC"); + + expect(startOfDay(new Date("2026-06-15T13:45:00.000Z")).toISOString()).toBe( + "2026-06-15T00:00:00.000Z", + ); + expect(endOfDay(new Date("2026-06-15T13:45:00.000Z")).toISOString()).toBe( + "2026-06-15T23:59:59.999Z", + ); + }); + + it("reads the calendar day in Africa/Lagos even when the UTC day differs", async () => { + const { startOfDay, endOfDay } = await helperFor("Africa/Lagos"); + const instant = new Date("2026-03-09T23:30:00.000Z"); + + expect(startOfDay(instant).toISOString()).toBe("2026-03-10T00:00:00.000Z"); + expect(endOfDay(instant).toISOString()).toBe("2026-03-10T23:59:59.999Z"); + }); + + it("follows daylight saving in America/New_York", async () => { + const { startOfDay } = await helperFor("America/New_York"); + + expect(startOfDay(new Date("2026-07-01T04:00:00.000Z")).toISOString()).toBe( + "2026-07-01T00:00:00.000Z", + ); + expect(startOfDay(new Date("2026-01-01T06:00:00.000Z")).toISOString()).toBe( + "2026-01-01T00:00:00.000Z", + ); + }); + + it("handles a zone ahead of UTC where the local day starts the day before", async () => { + const { startOfDay } = await helperFor("Pacific/Kiritimati"); + const instant = new Date("2025-12-31T23:00:00.000Z"); + + expect(startOfDay(instant).toISOString()).toBe("2026-01-01T00:00:00.000Z"); + }); + + it("uses the current instant when no argument is passed", async () => { + const { startOfDay, endOfDay } = await helperFor("Africa/Lagos"); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-09T23:30:00.000Z")); + + try { + expect(startOfDay().toISOString()).toBe("2026-03-10T00:00:00.000Z"); + expect(endOfDay().toISOString()).toBe("2026-03-10T23:59:59.999Z"); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/backend/src/lib/timezone.ts b/backend/src/lib/timezone.ts new file mode 100644 index 0000000..460a9b3 --- /dev/null +++ b/backend/src/lib/timezone.ts @@ -0,0 +1,23 @@ +import { env } from "../config/env.js"; + +function calendarDate(instant: Date, timeZone: string) { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(instant); + const pick = (type: string) => + Number(parts.find((p) => p.type === type)?.value); + return { year: pick("year"), month: pick("month"), day: pick("day") }; +} + +export function startOfDay(instant: Date = new Date()): Date { + const { year, month, day } = calendarDate(instant, env.TIMEZONE); + return new Date(Date.UTC(year, month - 1, day)); +} + +export function endOfDay(instant: Date = new Date()): Date { + const { year, month, day } = calendarDate(instant, env.TIMEZONE); + return new Date(Date.UTC(year, month - 1, day, 23, 59, 59, 999)); +} diff --git a/docs/scope/scope.md b/docs/scope/scope.md index 1ee0f5a..a579480 100644 --- a/docs/scope/scope.md +++ b/docs/scope/scope.md @@ -15,7 +15,7 @@ _These are recommendations to keep your build orderly, not requirements. Skip an | B | Backend scaffold | Foundation | in-progress | | 18 | Stack & architecture | Foundation | in-progress | | 1 | Coding standards & tooling | Foundation | done | -| 2 | Data model | Foundation | planned | +| 2 | Data model | Foundation | in-progress | | 3 | Design system & UI foundation | Foundation | planned | | 4 | Auth & roles | Slice 1 | planned | | 5 | Schedule & meetings | Slice 1 | planned | @@ -57,10 +57,18 @@ Capture conventions from the real scaffold, then install lint, format, and pre c - [x] Check it runs clean: `/test` Tooling in `package.json`, `.prettierrc.json`, `.husky/pre-commit`, `.husky/commit-msg`, `commitlint.config.js`, `.github/workflows/ci.yml`, `backend/eslint.config.mjs`, `backend/vitest.config.ts`, `AGENTS.md` -### 2. Data model · needs a decision +### 2. Data model · in-progress The core entities every later slice builds on: users and roles, meetings, tasks, contacts, follow ups, pending decisions, attachments, activity for trash. **Done when:** entities and relationships support every later slice (schedule, tasks, contacts, follow ups, decisions, briefings) without a breaking migration. -- [ ] Design it (spec): `/architect data model` +- [x] Design it (spec): `/architect data model` +- [x] Build it: /develop data model + - [x] Schema and migration: constants module, Prisma models, role config, one migration, satisfies AC-1, AC-2, AC-3, AC-5, AC-6, AC-9 + - [x] Write path: repository create helper, Activity wiring, soft delete conventions, satisfies AC-3, AC-8, AC-10 + - [x] Seed roles and timezone helper, satisfies AC-4, AC-7 + - [x] Schema level tests, satisfies AC-1, AC-3, AC-4, AC-5, AC-7 +- [x] Verify it: /check verify data model +- [x] Test it: /test data model +Spec [0002](../specs/0002-domain-data-model/index.md) · code in `backend/prisma/`, `backend/src/lib/` ### 3. Design system & UI foundation · needs a decision Visual language, layout, and base components so every screen feels like one product and works from the keyboard. diff --git a/docs/specs/0002-domain-data-model/index.md b/docs/specs/0002-domain-data-model/index.md new file mode 100644 index 0000000..a4f4a71 --- /dev/null +++ b/docs/specs/0002-domain-data-model/index.md @@ -0,0 +1,172 @@ +# 0002. Domain data model + +**Date**: 2026-10-10 +**Status**: In Progress + +## Summary + +This spec decides the core tables the whole product stores data in: users with roles, meetings, contacts, tasks, follow ups, pending decisions, attachments, notifications, and an activity log for trash. The model is designed once, for every later slice, so those slices can build on it without a breaking migration (a migration that would force rewrites of existing code or loss of data). The database is Postgres through Prisma, already decided in spec 0001, and Better Auth tables already exist; this spec adds the domain tables on top and a role column on User. + +The reasoning behind this decision lives in [rationale.md](rationale.md). + +## Requirements + +**User stories**: +- As the PA, I want one shared set of tables for meetings, tasks, contacts, follow ups, and decisions so that every screen reads the same truth. +- As the CEO, I want my role recorded on my user row so that read only and approval rules can be enforced consistently. +- As the PA, I want deleted items restorable later without losing history, so trash can be added without a breaking migration. + +**Acceptance criteria** (the contract, each criterion is IDed and independently checkable): +- **AC-1**: One migration creates Meeting, MeetingAttendee, Contact, Task, FollowUp, PendingDecision, Attachment, Notification, and Activity with the fields, relations, and foreign key delete rules in the data model sketch below, adds a role column to User with database default "PA" and a check constraint limiting it to "PA" or "CEO", backfills any existing user rows to "PA", and applies cleanly on a fresh database with the existing auth tables intact. +- **AC-2**: Meeting, Task, Contact, FollowUp, and PendingDecision each carry a nullable deletedAt column; User, MeetingAttendee, Attachment, Notification, and Activity do not. PendingDecision rows soft delete only while status is pending; decided rows are immutable. +- **AC-3**: Every new domain entity uses a String primary key holding a UUID v4 generated in one repository create helper via crypto.randomUUID; schema ids carry no database default, so every insert path is forced to pass an id. MeetingAttendee uses the composite primary key (meetingId, contactId) with no surrogate id. +- **AC-4**: User.role holds "PA" or "CEO", defaults to "PA" on insert, and is configured in Better Auth as an additional field with input disabled so no sign up request can set it. The seed ensures exactly one user per role (keys on role, falls back to email, repairs an existing row that has no role), and re running the seed stays idempotent. +- **AC-5**: Status, priority, activity action, attachment parent type, and attendee role label columns are plain strings (no database enums). Canonical tokens live in one backend constants module in lower snake case as listed in this spec, every status and priority column carries the `@default` named in the model sketch, and all writes, including seed and import, go through that module's validation. +- **AC-6**: Indexes exist on Meeting.startsAt, Task (status, dueAt), FollowUp.dueAt, PendingDecision (status, dueAt), Contact.name, Notification (userId, readAt), Attachment (parentType, parentId), and Activity (entityType, entityId), so dashboard, schedule, and list queries stay fast. +- **AC-7**: The backend reads a TIMEZONE environment variable (IANA zone, default UTC when unset, config load fails on an unparseable value). Meetings and due dates store naive local wall clock times (Prisma DateTime, mapped to a timestamp without time zone). No code constructs a Date for domain logic outside the timezone helper, which exposes startOfDay and endOfDay. A null dueAt means unscheduled and is excluded from due today queries. +- **AC-8**: The schema supports every later slice (schedule, tasks, contacts, follow ups, decisions, briefings, attachments, trash) without requiring a breaking migration; new slices may add columns or tables but must not rename or repurpose these. The reserved columns checklist in the feature design is the evidence. +- **AC-9**: Meeting, Contact, Task, FollowUp, and PendingDecision each carry a required createdById pointing at User. Import and bulk create paths default it to the PA user. +- **AC-10**: Soft-deleted parents never appear as live rows in any read that crosses a relation; those reads either omit the parent or show a plain deleted placeholder. + +## Decision + +**Chosen option**: Option 1: Full target model in one migration. + +Land the whole confirmed model as one Prisma migration on Postgres, add role to User with default PA, a check constraint, and Better Auth input disabled, reserve soft delete and activity columns now, keep statuses as validated strings owned by one constants module, assign UUIDs in one repository create helper, freeze foreign key delete rules in the first migration, and store the system timezone in one environment variable read through a single helper. + +**Implementation skills**: `prisma-orm-setup` (`prisma/skills`, `.agents/skills/prisma-orm-setup/`) · `prisma-cli` (`prisma/skills`, `.agents/skills/prisma-cli/`) · `prisma-client-api` (`prisma/skills`, `.agents/skills/prisma-client-api/`) + +## Feature design + +**Data model sketch**: + +Delete rules: Restrict on required parent references (Contact, Meeting, User), SetNull on optional related and decidedBy columns, Restrict on Activity.actorId and Attachment.uploadedById, Cascade on MeetingAttendee's two foreign keys. Slice 16 owns any hard purge and its order; this migration freezes the rules above so no later data forces a breaking change. + +| Entity | Key fields (type, nullability, defaults) | Relations (delete rule) | +|---|---|---| +| User | id String PK (UUID, library filled), Better Auth fields unchanged, role String @default("PA") with check constraint ("PA" or "CEO"), createdAt, updatedAt | parent of createdById, assignee, decidedBy, uploadedBy, actorId, userId columns | +| Meeting | id String PK (UUID), title String, startsAt DateTime, endsAt DateTime, location String?, notes String?, status String @default("scheduled") ("scheduled" or "cancelled"), createdById FK → User (Restrict, required), createdAt, updatedAt, deletedAt DateTime? | many to many Contact through MeetingAttendee; optional related Task, FollowUp, PendingDecision (SetNull) | +| MeetingAttendee | meetingId FK → Meeting (Cascade), contactId FK → Contact (Cascade), roleLabel String? ("required" or "optional"), composite primary key (meetingId, contactId) | join only, no soft delete, no surrogate id | +| Contact | id String PK (UUID), name String, company String?, email String?, phone String?, notes String?, createdById FK → User (Restrict, required), createdAt, updatedAt, deletedAt DateTime? | attendee of Meetings; optional source of Task and FollowUp (SetNull). No unique constraint on email (shared addresses are normal) | +| Task | id String PK (UUID), title String, description String?, dueAt DateTime? (null means unscheduled), priority String @default("medium") ("low", "medium", "high", "urgent"), status String @default("open") (see transitions), assigneeId FK → User (Restrict, required, defaults to PA on import and bulk paths), createdById FK → User (Restrict, required), relatedContactId FK → Contact? (SetNull), relatedMeetingId FK → Meeting? (SetNull), createdAt, updatedAt, deletedAt DateTime? | many to one User, Contact, Meeting | +| FollowUp | id String PK (UUID), title String, dueAt DateTime? (null means unscheduled), status String @default("pending") (see transitions), assigneeId FK → User (Restrict, required, defaults to PA on import and bulk paths), sourceContactId FK → Contact? (SetNull), sourceMeetingId FK → Meeting? (SetNull), createdById FK → User (Restrict, required), createdAt, updatedAt, deletedAt DateTime? | mirrors Task's link shape | +| PendingDecision | id String PK (UUID), title String, context String?, status String @default("pending") (see transitions), dueAt DateTime?, relatedMeetingId FK → Meeting? (SetNull), decidedById FK → User? (SetNull), decisionComment String?, decidedAt DateTime?, createdById FK → User (Restrict, required), createdAt, updatedAt, deletedAt DateTime? | CEO acts here; decidedBy null while pending; soft delete only while pending, decided rows immutable | +| Attachment | id String PK (UUID), fileName String, mimeType String, size Int, storagePath String (Cloudinary id or path in slice 13), parentType String ("meeting", "task", "contact", "follow_up"), parentId String, uploadedById FK → User (Restrict, required), createdAt, updatedAt | polymorphic pointer, no soft delete, no DB foreign key on parentId (application checks existence); index on (parentType, parentId); slice 16 purge deletes attachments before their parent | +| Notification | id String PK (UUID), userId FK → User (Restrict, required), type String (values owned by slice 6, for example task_due), title String, body String?, relatedType String?, relatedId String?, readAt DateTime?, createdAt | in app feed; email delivery joins in slice 6; composite index (userId, readAt) | +| Activity | id String PK (UUID), entityType String (closed set: meeting, contact, task, follow_up, pending_decision, attachment, notification, user), entityId String, action String ("created", "updated", "deleted", "restored"), actorId FK → User? (Restrict, never SetNull), payload Json? ({before, after} field delta, only changed keys), createdAt | append only, no soft delete, no updates; dangling entityId after a hard delete is intended audit residue | + +Table naming: Prisma default names for domain tables (Meeting, Task, and so on). The lowercase @@map names on auth tables stay, those are the library's convention. + +Canonical tokens live in one backend constants module (statuses, priorities, activity actions, attachment parent types, attendee role labels, activity entity types). Seed, import, and every repository write validate against it. Schema ids stay plain `String @id` with no `@default` so inserts must pass a UUID from the repository create helper. + +**State transitions** (canonical tokens in lower snake case, owned by the constants module): + +- Meeting: scheduled → cancelled, cancelled → scheduled (PA un cancels or trash restore). No other states. Default scheduled. +- Task: open → in_progress → done; open or in_progress → cancelled. done and cancelled are terminal except trash restore. Default open, priority default medium. +- FollowUp: pending → done, pending → dismissed. Both terminal except trash restore. Default pending. +- PendingDecision: pending → approved or declined. Terminal; decidedById, decisionComment, and decidedAt fill on the transition. Soft delete is allowed only while pending. The CEO triggers the transition in slice 11. Default pending. +- Trash restore: clears deletedAt, leaves status unchanged, writes an Activity row with action restored and the prior status in the payload. + +**API surface**: + +None in this feature. This is schema, migration, seed, and one env var. Each later slice owns its own endpoints; this model is the storage they read and write. + +**Value sourcing**: + +| Value produced or displayed | Source | +|---|---| +| New domain row id | one repository create helper using crypto.randomUUID; schema has no id default (AC-3) | +| Meeting wall clock times | input from the PA; stored in startsAt and endsAt as naive local times; interpreted in the TIMEZONE helper (AC-7) | +| createdById on domain rows | the signed in user's id; import and bulk paths default to the PA user (AC-9) | +| Actor on Activity rows | the signed in user's id; Restrict delete, never SetNull (AC-9) | +| Attachment.storagePath | upload result from Cloudinary in slice 13; column reserved now | +| Notification.userId and related ids | the user being notified and the source row's id | +| Status, priority, action, parent type, role label, entity type values | the backend constants module; lower snake case tokens listed in this spec (AC-5) | +| Timezone for day boundaries | TIMEZONE env var via the timezone helper's startOfDay and endOfDay; invalid values fail config load (AC-7) | + +**Key invariants**: + +- Meeting endsAt is after startsAt; enforced in the application write path, not the database. +- Status, priority, and the other closed set columns accept only tokens from the constants module. Seed, import, and repository writes all route through it; there is no database check on these, only on User.role. +- Default list and detail queries filter deletedAt IS NULL. Every read that crosses into a parent relation filters soft deleted parents and shows a plain deleted placeholder instead of a live looking join (AC-10). The trash feature (slice 16) queries the rest. +- PendingDecision soft deletes only while status is pending; a decided row is immutable so the approval trail survives. +- Restore clears deletedAt, leaves status unchanged, writes Activity action restored with the prior status in payload. +- Activity is append only: no updates, no deletes, actorId Restrict never SetNull. A dangling entityId after a hard delete is intended audit residue. +- The repository write path owns Activity rows (created, updated, deleted, restored) from the first feature onward; entityType comes from the closed set in the sketch; payload is a {before, after} object with only changed keys. +- Attachment.parentId must point at a live row of parentType; checked in the application because polymorphic relations have no single foreign key. Slice 16 hard purge deletes attachments before their parent. +- Overlapping meetings are not blocked at the database; slice 5 queries for overlaps and flags them in the UI (intentional double bookings stay possible). +- A null dueAt means unscheduled; due today and due soon queries exclude it. +- No code outside the timezone helper constructs a Date for domain logic; invalid TIMEZONE fails config load rather than falling through silently. + +**Security model**: + +Role lives on User as a plain string with database default "PA", a check constraint limiting values to "PA" or "CEO", and Better Auth additionalFields configured input disabled so no sign up request can set role. Feature 4 (auth and roles) owns session checks and route enforcement: the PA writes domain rows; the CEO reads and only transitions PendingDecision. This spec stores the column, constrains it, and seeds one of each role; it does not enforce authorization. Contact details and meeting notes are personal data for a single organization; no regulated scope (payments, health) applies. Secrets stay in environment variables, never in these tables. + +**Configuration required**: + +- `TIMEZONE`: IANA zone name for the CEO's home city, for example Africa/Lagos; default UTC when unset; config load fails on an unparseable value (AC-7). + +**Reserved columns checklist** (evidence for AC-8; later slices add behavior, not foundations): + +| Later slice | Already reserved here | Still owned by that slice | +|---|---|---| +| 6 Tasks and reminders | Notification table, task dueAt and status | type value set, email delivery join, reminder scheduling | +| 11 CEO view and approvals | PendingDecision status, decidedBy, decidedAt, decisionComment | route enforcement, comment UX | +| 13 File attachments | Attachment table, storagePath, parent polymorphic pointer | Cloudinary provider, upload limits, purge wiring with 16 | +| 15 External calendar sync | Meeting single rows, no recurrence column | provider choice, expansion strategy, external event key column added by that slice | +| 16 Trash and restore | deletedAt on five tables, Activity table and actions | restore UI, purge order, export | + +**Critical test scenarios** (each maps to an acceptance criterion in ## Requirements): +- Happy path: fresh database, run the migration, run the seed, find one PA user and one CEO user and ten new tables present, verifies **AC-1**, **AC-4** +- Happy path: repository create helper inserts a meeting with two attendees; ids are UUID v4, MeetingAttendee has no surrogate id, createdById and actor fill from the session user, verifies **AC-3**, **AC-9** +- Happy path: repository create and read of a meeting with two attendees round trips all fields including null optional columns, verifies **AC-1**, **AC-3** +- Failure case: a write with status "archived" on a task is rejected by the constants module before reaching the database, verifies **AC-5** +- Failure case: a sign up request that includes role=CEO is ignored or rejected; role stays at the database default, verifies **AC-4** +- Failure case: a read of a task whose related meeting is soft deleted omits the meeting or shows the deleted placeholder, never a live looking join, verifies **AC-10** +- Failure case: re running the seed creates no duplicate users, repairs a user missing a role, and leaves one user per role, verifies **AC-4** +- Failure case: loading config with TIMEZONE=Not/AZone fails rather than silently defaulting, verifies **AC-7** +- Auth/permission: not enforced in this feature; role values are stored and readable so feature 4 can gate routes, verifies **AC-4** + +## Build plan + +Tracer Bullet ordering: this feature is itself the thin real foundation thread (schema to database to seed), so the plan lands the whole model at once rather than thickening one entity at a time. One migration, matching option 1. + +1. [x] Create the backend constants module with the canonical tokens (statuses, priorities, activity actions, attachment parent types, attendee role labels, activity entity types) and their `@default` companions, satisfies **AC-5** +2. [x] Extend `backend/prisma/schema.prisma`: add role to User (default PA, check constraint), define the nine new models with createdById, foreign key delete rules, composite MeetingAttendee key, status and priority defaults, and the AC-6 composite indexes, satisfies **AC-1**, **AC-2**, **AC-3**, **AC-5**, **AC-6**, **AC-9** +3. [x] Configure Better Auth user.additionalFields for role (defaultValue PA, input false), satisfies **AC-4** +4. [x] Generate and apply the migration against the local Neon database; confirm a fresh migrate dev run is clean and existing user rows backfill to PA, satisfies **AC-1**. The CHECK constraint on role is hand written in the migration SQL, since Prisma has no schema syntax for it; a later `migrate dev` run reports no schema changes, so the constraint does not count as drift. +5. [x] Add one repository create helper that assigns UUID v4 ids and routes writes through the constants module; wire Activity created and updated writes into the repository path, satisfies **AC-3**, **AC-5** +6. [x] Update `backend/prisma/seed.ts` to key on role (fall back to email), set PA and CEO explicitly, and repair a user that exists without a role, keeping the idempotent exists check, satisfies **AC-4**. `signUpEmail` returns `{ token, user }`, so the CEO role update reads `created.user.id`. +7. [x] Add `TIMEZONE` to backend config with UTC default, fail on unparseable values, and expose one timezone helper with startOfDay and endOfDay that all date logic reads, satisfies **AC-7** +8. [x] Document in the backend conventions (root AGENTS.md or a short comment in the prisma lib) that default queries filter deletedAt, relation reads filter soft deleted parents, and statuses validate against the constants module, satisfies **AC-2**, **AC-5**, **AC-8**, **AC-10**. Recorded as one comment line at the top of `backend/src/lib/prisma.ts`; `/sync` should promote it into root `AGENTS.md`. +9. [x] Write schema level tests: migrate fresh, seed twice, assert one user per role, assert UUID v4 format on new ids, assert invalid status and invalid TIMEZONE are rejected (extends the existing seed test), satisfies **AC-1**, **AC-3**, **AC-4**, **AC-5**, **AC-7**. The fresh database suite lives in `backend/src/lib/schema.test.ts` and skips itself when `DATABASE_URL_UNPOOLED` is unset (CI) or the database cannot be reached. + +## Consequences + +**Positive**: +- Every later slice starts from a complete schema; schedule, tasks, contacts, follow ups, decisions, and trash need no foundation migration. +- Trash can land as a pure feature (UI and restore logic) because deletedAt and Activity already exist, and restore semantics are defined. +- Foreign key delete rules, status defaults, and the role insert path are frozen in migration 1, so no later slice re-litigates them against live data. +- One review of the whole model catches relation mistakes while the schema is still cheap to change. + +**Negative / tradeoffs**: +- Attachment, Notification, and Activity tables ship before their features; their shapes get less real use feedback until slices 6, 11, and 13. +- Soft delete means every default query must remember the deletedAt filter, and every relation read must filter soft deleted parents; forgetting either shows ghost rows. The shared helper and the documented convention are the mitigation. +- Adding a status value later is easy (string set in the constants module), which trades database level completeness for discipline in the application validation layer. User.role is the one column with a database check, because that set never changes. +- Single tenancy is now a hard assumption; a second CEO in one deployment would be a breaking redesign, accepted for this product. +- A required assignee on Task and FollowUp means "unassigned" is foreclosed; relaxing it later changes generated types and counts as breaking under AC-8. +- App-side id generation means every insert path must remember to call the repository create helper; a direct prisma create that skips it fails on the missing primary key, which is loud but still a footgun for anyone bypassing the repository. + +**Neutral**: +- Better Auth continues to own Session, Account, and Verification; User changes only by the role column and its additionalFields configuration. +- Cloudinary, email delivery, and calendar sync providers are not chosen here; the columns reserve their places (storagePath, Notification delivery, Meeting single rows). +- Prisma client types regenerate from the schema; repositories and later use cases import the generated client from `backend/src/generated/prisma` as today. +- MeetingAttendee uses a composite primary key rather than a surrogate id; slightly less idiomatic Prisma, one fewer index and one fewer invented UUID. + +## Follow-up + +- [ ] Feature 4 (auth and roles) wires session middleware that reads User.role and enforces PA write / CEO read plus decision transitions; this spec only stores and constrains the column. +- [ ] When the first feature needs list queries, add a small shared soft delete filter helper in the backend lib so every repository does not reinvent deletedAt IS NULL and the relation parent filter (AC-10). +- [ ] Slice 6 owns the Notification.type value set and the email delivery join; slice 13 owns Cloudinary wiring for Attachment.storagePath; slice 16 owns hard purge order and export; none is chosen in this spec beyond the reserved columns. +- [ ] Prisma schema and migration conventions from the installed prisma skills should be reflected in root AGENTS.md Rules if they are not already, so every later task follows them without rediscovery. +- [ ] Import (slice 14) and any bulk path must default createdById and assigneeId to the PA user and route status writes through the constants module; call this out when that slice is designed. diff --git a/docs/specs/0002-domain-data-model/rationale.md b/docs/specs/0002-domain-data-model/rationale.md new file mode 100644 index 0000000..6c0eeb5 --- /dev/null +++ b/docs/specs/0002-domain-data-model/rationale.md @@ -0,0 +1,39 @@ +# 0002. Domain data model: reasoning + +## Context + +Every feature after the foundation reads or writes shared rows: the schedule needs meetings and attendees, the dashboard needs tasks, decisions, and commitments, the briefing compiles from all of them, and trash needs a way to delete without destroying. If each slice invents its own tables and keys, the schema fragments and later slices force breaking migrations. The scope feature "Data model" exists so one coherent model lands first. + +Forces that shaped this: one CEO and one PA use the system (single tenant, no tenant key on rows); the PA edits everything while the CEO reads and acts on approvals only (a role must live somewhere queryable); deleted items must be restorable (soft delete columns and an activity log, reserved now even though the trash UI is slice 16); the daily briefing assembles from live data rather than stored snapshots (no briefing table); and external calendar sync comes later (meetings stay single rows for now, recurrence can expand later without redesign). + +The existing schema holds only the Better Auth tables (User, Session, Account, Verification). User gains a role column; the auth tables otherwise stay as the library owns them. + +## Options considered + +### Option 1: Full target model in one migration (chosen) + +Define all ten entities and the User.role column now, apply one migration, and let every later slice write against a complete schema. + +**Pros**: Matches the done when of no breaking migration; later slices only add features, not foundations; one review of the whole shape. +**Cons**: The first migration is large; some tables (Attachment, Notification) sit unused until their slices, which slightly delays feedback on those shapes. + +### Option 2: Skeleton only + +Model only what slice 1 needs (User.role, Meeting, MeetingAttendee) and grow the rest slice by slice. + +**Pros**: Smallest first migration, fastest feedback on the schedule path. +**Cons**: Every later slice migrates new tables and backfills relations; the done when of supporting every slice without a breaking migration is not met at foundation time. + +### Option 3: Full model, migration sliced across slices + +Design the whole target in the spec but land the migration piecemeal as each slice builds. + +**Cons for this project**: The Tracer Bullet default still wants one thin real thread, but a data model is not a vertical thread; splitting one logical migration across time recreates the churn option 2 has, with none of its speed. Rejected. + +## Rationale + +The scope names this feature as the foundation every later slice builds on, and its done when is explicit: support schedule, tasks, contacts, follow ups, decisions, and briefings without a breaking migration. That rules out a skeleton. Slicing the migration across slices was weighed and rejected because a schema is one coherent artifact; partial application buys little and keeps every later feature opening with migration risk. + +Single tenancy was confirmed against the product (one CEO, one PA), so no tenant column appears anywhere; adding multi tenancy later would be a breaking change, and that tradeoff is accepted consciously. Soft delete was reserved now, against a general preference for leaner deletes, because trash and restore is a named slice and retrofitting deletedAt across five tables after data exists is the expensive path. The activity log stays generic (entity type, entity id, action, actor, payload JSON) so trash restore and a basic change trail share one append only table without field level history overhead. + +Statuses are strings with fixed sets rather than database enums because the sets are small, application validation already sits at the write boundary, and adding a value should not require a migration. Recurrence stays out of Meeting (single rows only) so external calendar sync in slice 15 can choose expansion or rules without this model having guessed wrong. The briefing stays a read time assembly, no table, matching the scope description that it compiles from the system's own data. diff --git a/docs/specs/0002-domain-data-model/verify.md b/docs/specs/0002-domain-data-model/verify.md new file mode 100644 index 0000000..1d95f10 --- /dev/null +++ b/docs/specs/0002-domain-data-model/verify.md @@ -0,0 +1,71 @@ +# Verify: Domain data model · spec 0002 · updated 2026-10-10 + +_Steps derived from spec 0002 and the feature's Value sourcing table. `/check verify` runs these; `/test` locks the durable ones._ + +Acceptance criteria, taken from the spec's Requirements: + +- AC-1 one migration creates the nine tables plus User.role (default "PA", check constrained), backfills existing users to PA, applies cleanly on a fresh database with the auth tables intact +- AC-2 deletedAt on Meeting, Task, Contact, FollowUp, PendingDecision; not on User, MeetingAttendee, Attachment, Notification, Activity +- AC-3 String UUID v4 ids from one repository create helper, no database id default, MeetingAttendee composite primary key (meetingId, contactId) +- AC-4 role PA or CEO, default PA, Better Auth input disabled, seed keys on role and stays idempotent +- AC-5 status, priority, action, parent type, role label, entity type are plain strings validated by one constants module, schema defaults match +- AC-6 the eight named indexes exist +- AC-7 TIMEZONE env var (IANA, UTC default, config fails on a bad value), naive local wall clock times, startOfDay and endOfDay in one helper, null dueAt means unscheduled +- AC-8 the reserved columns checklist holds so no later slice needs a breaking migration (a design claim, not a runnable step) +- AC-9 createdById on Meeting, Contact, Task, FollowUp, PendingDecision pointing at User; Activity.actorId Restrict +- AC-10 soft-deleted parents never appear as live rows in a read that crosses a relation + +## UI / manual + +None. This feature ships no UI and no API surface. The database checks below are the observable surfaces. + +## Database + +- [x] `psql "$DATABASE_URL_UNPOOLED" -c '\dt'` → auth tables plus Meeting, MeetingAttendee, Contact, Task, FollowUp, PendingDecision, Attachment, Notification, Activity → AC-1 +- [x] `psql "$DATABASE_URL_UNPOOLED" -c '\d "user"'` → role text NOT NULL DEFAULT 'PA' with user_role_check limiting it to PA or CEO → AC-1, AC-4 +- [x] `psql "$DATABASE_URL_UNPOOLED" -c 'SELECT email, role FROM "user" ORDER BY role'` → exactly one PA and one CEO, every row carries a role → AC-4 +- [x] `psql "$DATABASE_URL_UNPOOLED" -c '\d "MeetingAttendee"'` → no id column, primary key on (meetingId, contactId) → AC-3 +- [x] `psql "$DATABASE_URL_UNPOOLED" -c '\d "Meeting"'` → startsAt and endsAt are timestamp without time zone, deletedAt nullable, createdById NOT NULL → AC-2, AC-7, AC-9 +- [x] `psql "$DATABASE_URL_UNPOOLED" -c "SELECT table_name FROM information_schema.columns WHERE column_name='deletedAt' AND table_schema='public' ORDER BY 1"` → Meeting, Contact, Task, FollowUp, PendingDecision and nothing else → AC-2 +- [x] `psql "$DATABASE_URL_UNPOOLED" -c "SELECT tablename, indexname FROM pg_indexes WHERE schemaname='public' ORDER BY 1"` → indexes on Meeting(startsAt), Task(status, dueAt), FollowUp(dueAt), PendingDecision(status, dueAt), Contact(name), Notification(userId, readAt), Attachment(parentType, parentId), Activity(entityType, entityId) → AC-6 +- [x] `psql "$DATABASE_URL_UNPOOLED" -c "\d \"Task\""` → assigneeId and createdById NO ACTION (Restrict) on User, relatedContactId and relatedMeetingId SET NULL → AC-9 +- [x] `psql "$DATABASE_URL_UNPOOLED" -c "\d \"Activity\""` → actorId SET NULL is absent, it is NO ACTION, and payload is jsonb → AC-9 + +## Commands + +- [x] `npm --prefix backend run db:migrate` → "Already in sync, no schema change or pending migration was found", no new migration file → AC-1 +- [x] `npm --prefix backend run seed` → `exists PA pa@example.com`, then `repaired ceo@example.com role -> CEO` on a stale row, then one PA and one CEO → AC-4 +- [x] `npm --prefix backend run seed` again → both lines say `exists`, user count stays at 2 → AC-4 +- [x] `npm --prefix backend test` → 12 files, 67 tests pass, including the fresh database suite that migrates into a scratch schema → AC-1, AC-3, AC-4, AC-5 +- [x] `npm --prefix backend test -- src/lib/timezone.test.ts` → 5 pass, the four zone tests stub their own zone (UTC, Africa/Lagos, America/New_York, Pacific/Kiritimati) → AC-7 +- [x] `npm --prefix backend test -- src/config/env.test.ts` → 7 pass, TIMEZONE unset defaults to UTC in a spawned child, Africa/Lagos accepted, Not/AZone rejected → AC-7 +- [x] `cd backend && TIMEZONE=Not/AZone npx tsx src/index.ts` → exit 1, prints `TIMEZONE: must be a valid IANA time zone`, never prints the listening line → AC-7 +- [x] `npm run verify` → exit 0, prettier clean, both lints clean, both typechecks clean → conventions + +## Value sourcing, one step each + +- [x] New domain row id: created a meeting through the repository helper (scratch script in /tmp, then removed) → id `dfb362da-a74e-4aa1-a4c6-d12dcaffc273`, matched the UUID v4 pattern → AC-3 +- [x] Meeting wall clock: wrote startsAt as 2026-10-10T23:30:00.000Z, read it back identical, and psql showed the stored value as `2026-10-10 23:30:00` with no offset, so the column is naive wall clock → AC-7 +- [x] createdById: the PA user id `oKtjtNpCnx7KXb0fCIYpcd5niks6RlvB` appeared on the meeting and matched the actor → AC-9 +- [x] Activity actor: two rows for the meeting, action `created` with the full after payload, then action `updated` with before and after holding only the changed title key, both with actorId set → AC-3, AC-9 +- [x] Status tokens: a raw insert of status `archived` on Task was accepted by the database (rolled back), and the same write through the repository was rejected with `status must be one of open, in_progress, done, cancelled` → AC-5 +- [x] Invalid TIMEZONE: the config fail step above, plus a raw insert of role `ROOT` was refused live by `user_role_check` → AC-7 + +## Acceptance-criteria coverage + +- AC-1 met: migration listing, role column and check constraint, clean re migrate +- AC-2 met: deletedAt present on exactly the five specced tables +- AC-3 met: MeetingAttendee composite key with no id column, UUID v4 from the repository helper +- AC-4 met: one PA and one CEO after the seed, idempotent second run, live refusal of a role outside the pair +- AC-5 met: constants module rejects `archived` while the database accepts it, defaults match the sketch +- AC-6 met: all eight indexes present +- AC-7 met: UTC default proven in a spawned child, zone math proven per zone, config refuses a bad zone with exit 1, naive wall clock proven at the column +- AC-8 met by design: the reserved columns checklist in index.md is the evidence, nothing to run here +- AC-9 met: createdById on all five specced tables, actorId Restrict, and a live delete of a referenced user was refused +- AC-10 met as convention only: no product read crosses a relation yet (grep of backend/src finds no find or aggregate call on the domain tables), so there is no surface to violate the rule. The convention sits in the one line comment at the top of backend/src/lib/prisma.ts. The first feature that reads across a relation must prove the parent filter, then the step here should be replaced with a live check. + +## Known edges worth varying + +- [x] Restrict on a referenced user: inside one transaction insert a Meeting whose createdById is the PA user, then `DELETE FROM "user" WHERE email = 'pa@example.com'` → Postgres refuses with `violates RESTRICT setting of foreign key constraint "Meeting_createdById_fkey"`. Note the bare delete with no referencing rows succeeds, so this step needs the meeting row first (rolled back in the run above) → AC-9 +- [x] Set TIMEZONE=America/New_York and repeat the helper tests → the four zone tests still pass because each stubs its own zone, and the one test that reads the ambient process env (`falls back to UTC when TIMEZONE is unset`) fails with `expected 'Africa/Lagos' to be 'UTC'`. That is the proof the helper reads the env var rather than a fixed zone. Follow up for `/test`: that single assertion in `backend/src/lib/timezone.test.ts` depends on the test process having no TIMEZONE exported, so a developer or CI that exports TIMEZONE sees one red test while the product is fine. `backend/src/config/env.test.ts` already does this correctly by deleting TIMEZONE in a spawned child → AC-7 +- [x] Insert a MeetingAttendee row with a roleLabel of `optional` and then `mandatory` in one transaction → the database stored both, because only User.role is check constrained; the constants module is what bounds the set (rolled back in the run above) → AC-5