feat(commissions): add customer records and kanban workflow baseline

This commit is contained in:
2026-02-12 20:01:49 +01:00
parent f65a9ea03f
commit 994b33e081
10 changed files with 755 additions and 18 deletions

View File

@@ -0,0 +1,52 @@
-- CreateTable
CREATE TABLE "Customer" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"instagram" TEXT,
"notes" TEXT,
"isRecurring" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Customer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Commission" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"status" TEXT NOT NULL,
"customerId" TEXT,
"assignedUserId" TEXT,
"budgetMin" DOUBLE PRECISION,
"budgetMax" DOUBLE PRECISION,
"dueAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Commission_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Customer_email_idx" ON "Customer"("email");
-- CreateIndex
CREATE INDEX "Customer_isRecurring_idx" ON "Customer"("isRecurring");
-- CreateIndex
CREATE INDEX "Commission_status_idx" ON "Commission"("status");
-- CreateIndex
CREATE INDEX "Commission_customerId_idx" ON "Commission"("customerId");
-- CreateIndex
CREATE INDEX "Commission_assignedUserId_idx" ON "Commission"("assignedUserId");
-- AddForeignKey
ALTER TABLE "Commission" ADD CONSTRAINT "Commission_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Commission" ADD CONSTRAINT "Commission_assignedUserId_fkey" FOREIGN KEY ("assignedUserId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -34,6 +34,7 @@ model User {
isProtected Boolean @default(false)
sessions Session[]
accounts Account[]
commissions Commission[] @relation("CommissionAssignee")
@@unique([email])
@@index([role])
@@ -302,3 +303,39 @@ model NavigationItem {
@@index([parentId])
@@unique([menuId, parentId, sortOrder, label])
}
model Customer {
id String @id @default(uuid())
name String
email String?
phone String?
instagram String?
notes String?
isRecurring Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
commissions Commission[]
@@index([email])
@@index([isRecurring])
}
model Commission {
id String @id @default(uuid())
title String
description String?
status String
customerId String?
assignedUserId String?
budgetMin Float?
budgetMax Float?
dueAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
assignedUser User? @relation("CommissionAssignee", fields: [assignedUserId], references: [id], onDelete: SetNull)
@@index([status])
@@index([customerId])
@@index([assignedUserId])
}

View File

@@ -158,6 +158,54 @@ async function main() {
},
})
}
const existingCustomer = await db.customer.findFirst({
where: {
email: "collector@example.com",
},
select: {
id: true,
},
})
const seededCustomer = existingCustomer
? await db.customer.update({
where: {
id: existingCustomer.id,
},
data: {
name: "Collector One",
phone: "+1-555-0101",
isRecurring: true,
notes: "Interested in recurring portrait commissions.",
},
})
: await db.customer.create({
data: {
name: "Collector One",
email: "collector@example.com",
phone: "+1-555-0101",
isRecurring: true,
notes: "Interested in recurring portrait commissions.",
},
})
await db.commission.upsert({
where: {
id: "11111111-1111-1111-1111-111111111111",
},
update: {},
create: {
id: "11111111-1111-1111-1111-111111111111",
title: "Portrait Commission Baseline",
description: "Initial seeded commission request for MVP1 board validation.",
status: "new",
customerId: seededCustomer.id,
budgetMin: 400,
budgetMax: 900,
dueAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
},
})
}
main()

View File

@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const { mockDb } = vi.hoisted(() => ({
mockDb: {
customer: {
create: vi.fn(),
findMany: vi.fn(),
},
commission: {
create: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
},
}))
vi.mock("./client", () => ({
db: mockDb,
}))
import { createCommission, createCustomer, updateCommissionStatus } from "./commissions"
describe("commissions service", () => {
beforeEach(() => {
for (const value of Object.values(mockDb)) {
for (const fn of Object.values(value)) {
if (typeof fn === "function") {
fn.mockReset()
}
}
}
})
it("creates customer and commission payloads", async () => {
mockDb.customer.create.mockResolvedValue({ id: "customer-1" })
mockDb.commission.create.mockResolvedValue({ id: "commission-1" })
await createCustomer({
name: "Ada Lovelace",
email: "ada@example.com",
isRecurring: true,
})
await createCommission({
title: "Portrait Request",
status: "new",
customerId: "550e8400-e29b-41d4-a716-446655440000",
})
expect(mockDb.customer.create).toHaveBeenCalledTimes(1)
expect(mockDb.commission.create).toHaveBeenCalledTimes(1)
})
it("updates commission status", async () => {
mockDb.commission.update.mockResolvedValue({ id: "commission-1", status: "done" })
await updateCommissionStatus({
id: "550e8400-e29b-41d4-a716-446655440001",
status: "done",
})
expect(mockDb.commission.update).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,66 @@
import {
commissionStatusSchema,
createCommissionInputSchema,
createCustomerInputSchema,
updateCommissionStatusInputSchema,
} from "@cms/content"
import { db } from "./client"
export const commissionKanbanOrder = commissionStatusSchema.options
export async function listCustomers(limit = 200) {
return db.customer.findMany({
orderBy: [{ updatedAt: "desc" }],
take: limit,
})
}
export async function createCustomer(input: unknown) {
const payload = createCustomerInputSchema.parse(input)
return db.customer.create({
data: payload,
})
}
export async function listCommissions(limit = 300) {
return db.commission.findMany({
orderBy: [{ updatedAt: "desc" }],
take: limit,
include: {
customer: {
select: {
id: true,
name: true,
email: true,
isRecurring: true,
},
},
assignedUser: {
select: {
id: true,
name: true,
username: true,
},
},
},
})
}
export async function createCommission(input: unknown) {
const payload = createCommissionInputSchema.parse(input)
return db.commission.create({
data: payload,
})
}
export async function updateCommissionStatus(input: unknown) {
const payload = updateCommissionStatusInputSchema.parse(input)
return db.commission.update({
where: { id: payload.id },
data: { status: payload.status },
})
}

View File

@@ -1,4 +1,12 @@
export { db } from "./client"
export {
commissionKanbanOrder,
createCommission,
createCustomer,
listCommissions,
listCustomers,
updateCommissionStatus,
} from "./commissions"
export {
attachArtworkRendition,
createAlbum,