From 0fe6da884d2fde871ba1f7b9e9819181e4abfc2b Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Sat, 16 May 2026 15:50:47 +0100 Subject: [PATCH] MAI CALL - step 7 --- packages/api/src/routers/_app.ts | 2 + .../api/src/routers/maintenance-request.ts | 207 ++++++++++++++++++ packages/db/src/tenant-extension.ts | 9 +- scripts/maintenance-smoke.ts | 133 +++++++++++ 4 files changed, 346 insertions(+), 5 deletions(-) create mode 100644 packages/api/src/routers/maintenance-request.ts create mode 100644 scripts/maintenance-smoke.ts diff --git a/packages/api/src/routers/_app.ts b/packages/api/src/routers/_app.ts index 81471b2..aeea47c 100644 --- a/packages/api/src/routers/_app.ts +++ b/packages/api/src/routers/_app.ts @@ -3,12 +3,14 @@ import { pingRouter } from './ping'; import { workstationRouter } from './workstation'; import { userRouter } from './user'; import { storageRouter } from './storage'; +import { maintenanceRequestRouter } from './maintenance-request'; export const appRouter = router({ ping: pingRouter, workstation: workstationRouter, user: userRouter, storage: storageRouter, + maintenanceRequest: maintenanceRequestRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/api/src/routers/maintenance-request.ts b/packages/api/src/routers/maintenance-request.ts new file mode 100644 index 0000000..0bdcbf4 --- /dev/null +++ b/packages/api/src/routers/maintenance-request.ts @@ -0,0 +1,207 @@ +import { TRPCError } from '@trpc/server'; +import { Prisma } from '@repo/db'; +import { z } from 'zod'; +import { protectedProcedure, requireRole, router } from '../trpc'; + +const photoKeySchema = z + .string() + .regex(/^tenants\/[a-z0-9-]+\/maintenance\/[a-z0-9-]+\.(jpg|jpeg|png|webp)$/); + +const statusSchema = z.enum(['OPEN', 'CLAIMED', 'RESOLVED']); + +const REQUEST_INCLUDE = { + workstation: { select: { id: true, code: true, name: true, area: true } }, + reportedBy: { select: { id: true, email: true } }, + claimedBy: { select: { id: true, email: true } }, + resolvedBy: { select: { id: true, email: true } }, +} as const; + +// Prisma's interactive-transaction client does not support $extends, so +// tenantScoped() cannot be used inside $transaction callbacks. Instead, +// tenantId is injected manually into each where/data clause below. + +export const maintenanceRequestRouter = router({ + create: protectedProcedure + .input( + z.object({ + workstationId: z.string().cuid(), + description: z.string().trim().min(3).max(1000), + photoKey: photoKeySchema.optional(), + clientRequestId: z.string().uuid(), + }), + ) + .mutation(async ({ ctx, input }) => { + const tid = ctx.tenantId; + try { + return await ctx.prisma.$transaction(async (tx) => { + const request = await tx.maintenanceRequest.create({ + data: { + tenantId: tid, + workstationId: input.workstationId, + reportedByUserId: ctx.user.id, + description: input.description, + photoKey: input.photoKey, + clientRequestId: input.clientRequestId, + }, + select: { id: true, status: true, createdAt: true }, + }); + await tx.domainEvent.create({ + data: { + tenantId: tid, + aggregateType: 'MaintenanceRequest', + aggregateId: request.id, + eventType: 'created', + payload: { + clientRequestId: input.clientRequestId, + reportedByUserId: ctx.user.id, + workstationId: input.workstationId, + }, + }, + }); + return request; + }); + } catch (err) { + // Idempotent: same clientRequestId from this tenant → return existing row. + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + const existing = await ctx.db.maintenanceRequest.findFirst({ + where: { clientRequestId: input.clientRequestId }, + select: { id: true, status: true, createdAt: true }, + }); + if (!existing) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR' }); + return existing; + } + throw err; + } + }), + + queue: requireRole('ADMIN', 'SUPERVISOR') + .input( + z.object({ + statuses: z.array(statusSchema).optional(), + area: z.string().optional(), + cursor: z.string().optional(), + limit: z.number().int().min(1).max(100).default(20), + }), + ) + .query(async ({ ctx, input }) => { + const items = await ctx.db.maintenanceRequest.findMany({ + where: { + ...(input.statuses?.length ? { status: { in: input.statuses } } : {}), + ...(input.area ? { workstation: { area: input.area } } : {}), + }, + include: REQUEST_INCLUDE, + orderBy: { createdAt: 'desc' }, + take: input.limit + 1, + ...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}), + }); + const hasMore = items.length > input.limit; + const page = hasMore ? items.slice(0, input.limit) : items; + return { + items: page, + nextCursor: hasMore ? (page[page.length - 1]?.id ?? undefined) : undefined, + }; + }), + + myRecent: protectedProcedure + .input(z.object({ limit: z.number().int().min(1).max(50).default(10) })) + .query(({ ctx, input }) => { + return ctx.db.maintenanceRequest.findMany({ + where: { reportedByUserId: ctx.user.id }, + include: REQUEST_INCLUDE, + orderBy: { createdAt: 'desc' }, + take: input.limit, + }); + }), + + claim: requireRole('ADMIN', 'SUPERVISOR') + .input(z.object({ id: z.string().cuid() })) + .mutation(async ({ ctx, input }) => { + const tid = ctx.tenantId; + return ctx.prisma.$transaction(async (tx) => { + const existing = await tx.maintenanceRequest.findFirst({ + where: { id: input.id, tenantId: tid }, + select: { status: true }, + }); + if (!existing) throw new TRPCError({ code: 'NOT_FOUND' }); + if (existing.status !== 'OPEN') { + throw new TRPCError({ + code: 'CONFLICT', + message: `Cannot claim: status is ${existing.status}, expected OPEN.`, + }); + } + const updated = await tx.maintenanceRequest.update({ + where: { id: input.id }, + data: { status: 'CLAIMED', claimedByUserId: ctx.user.id, claimedAt: new Date() }, + include: REQUEST_INCLUDE, + }); + await tx.domainEvent.create({ + data: { + tenantId: tid, + aggregateType: 'MaintenanceRequest', + aggregateId: input.id, + eventType: 'claimed', + payload: { claimedByUserId: ctx.user.id }, + }, + }); + return updated; + }); + }), + + resolve: requireRole('ADMIN', 'SUPERVISOR') + .input( + z.object({ + id: z.string().cuid(), + resolutionNote: z.string().trim().max(2000).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const tid = ctx.tenantId; + return ctx.prisma.$transaction(async (tx) => { + const existing = await tx.maintenanceRequest.findFirst({ + where: { id: input.id, tenantId: tid }, + select: { status: true }, + }); + if (!existing) throw new TRPCError({ code: 'NOT_FOUND' }); + if (existing.status !== 'CLAIMED') { + throw new TRPCError({ + code: 'CONFLICT', + message: `Cannot resolve: status is ${existing.status}, expected CLAIMED.`, + }); + } + const updated = await tx.maintenanceRequest.update({ + where: { id: input.id }, + data: { + status: 'RESOLVED', + resolvedByUserId: ctx.user.id, + resolvedAt: new Date(), + resolutionNote: input.resolutionNote, + }, + include: REQUEST_INCLUDE, + }); + await tx.domainEvent.create({ + data: { + tenantId: tid, + aggregateType: 'MaintenanceRequest', + aggregateId: input.id, + eventType: 'resolved', + payload: { + resolvedByUserId: ctx.user.id, + resolutionNote: input.resolutionNote ?? null, + }, + }, + }); + return updated; + }); + }), + + getById: protectedProcedure + .input(z.object({ id: z.string().cuid() })) + .query(async ({ ctx, input }) => { + const request = await ctx.db.maintenanceRequest.findFirst({ + where: { id: input.id }, + include: REQUEST_INCLUDE, + }); + if (!request) throw new TRPCError({ code: 'NOT_FOUND' }); + return request; + }), +}); diff --git a/packages/db/src/tenant-extension.ts b/packages/db/src/tenant-extension.ts index 38716f3..c753fd0 100644 --- a/packages/db/src/tenant-extension.ts +++ b/packages/db/src/tenant-extension.ts @@ -58,11 +58,10 @@ import type { PrismaClient } from '@prisma/client'; * The extension does not re-wrap the inner transactional client. Either: * - issue all operations through the outer scoped client and use the * sequential array form: `await db.$transaction([op1, op2])`, OR - * - if you need the interactive form, scope explicitly: - * await prisma.$transaction(async (tx) => { - * const scopedTx = tenantScoped(tx as PrismaClient, tenantId); - * ... - * }); + * - if you need the interactive form, inject tenantId manually into + * every where/data clause inside the callback. The transaction + * client does NOT support $extends (it is in ITXClientDenyList), + * so tenantScoped(tx, tenantId) throws at runtime. * * 3. Models without tenantId (currently only `Tenant`). These are passed * through unchanged. Code touching Tenant must take care not to leak diff --git a/scripts/maintenance-smoke.ts b/scripts/maintenance-smoke.ts new file mode 100644 index 0000000..b367587 --- /dev/null +++ b/scripts/maintenance-smoke.ts @@ -0,0 +1,133 @@ +/** + * AC verification for Passo 7: + * op1 creates a request → admin claims → admin resolves → queue shows correct order. + * Repeat create with same clientRequestId returns the same row without error. + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { config as loadEnv } from 'dotenv'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +loadEnv({ path: path.resolve(here, '../.env') }); + +import { prisma } from '../packages/db/src/index.js'; +import { appRouter, createTRPCContext } from '../packages/api/src/index.js'; +import { createCallerFactory } from '../packages/api/src/trpc.js'; + +async function makeCaller(email: string) { + const user = await prisma.user.findFirst({ where: { email } }); + if (!user) throw new Error(`User ${email} not found — run pnpm db:seed`); + const ctx = await createTRPCContext({ + user: { id: user.id, email: user.email, role: user.role as 'ADMIN' | 'OPERATOR', tenantId: user.tenantId }, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx); +} + +async function main() { + const op1 = await makeCaller('op1@demo.local'); + const admin = await makeCaller('admin@demo.local'); + + // Find a workstation to use + const workstations = await admin.workstation.list(); + const ws = workstations[0]; + if (!ws) throw new Error('No workstations found — run pnpm db:seed'); + + const clientRequestId = crypto.randomUUID(); + + // --- create --- + console.log('1. op1 creates a maintenance request...'); + const created = await op1.maintenanceRequest.create({ + workstationId: ws.id, + description: 'Barulho anormal no posto CTR04 — teste smoke', + clientRequestId, + }); + if (created.status !== 'OPEN') throw new Error(`Expected OPEN, got ${created.status}`); + console.log(` Created id=${created.id} status=${created.status} ✓`); + + // --- idempotent duplicate --- + console.log('2. Repeating create with same clientRequestId...'); + const duplicate = await op1.maintenanceRequest.create({ + workstationId: ws.id, + description: 'Duplicado — deve devolver a row existente', + clientRequestId, + }); + if (duplicate.id !== created.id) { + throw new Error(`Idempotency failed: got id=${duplicate.id}, expected ${created.id}`); + } + console.log(` Same id returned (${duplicate.id}) ✓`); + + // --- getById --- + console.log('3. getById...'); + const fetched = await op1.maintenanceRequest.getById({ id: created.id }); + if (fetched.workstation.code !== ws.code) throw new Error('workstation relation missing'); + console.log(` workstation=${fetched.workstation.code}, reportedBy=${fetched.reportedBy.email} ✓`); + + // --- claim --- + console.log('4. admin claims the request...'); + const claimed = await admin.maintenanceRequest.claim({ id: created.id }); + if (claimed.status !== 'CLAIMED') throw new Error(`Expected CLAIMED, got ${claimed.status}`); + console.log(` status=${claimed.status} claimedBy=${claimed.claimedBy?.email} ✓`); + + // --- cannot claim again --- + console.log('5. Claiming again should return CONFLICT...'); + try { + await admin.maintenanceRequest.claim({ id: created.id }); + throw new Error('Expected CONFLICT but succeeded'); + } catch (err: unknown) { + const trpcErr = err as { code?: string }; + if (trpcErr.code !== 'CONFLICT') throw err; + console.log(' CONFLICT returned ✓'); + } + + // --- resolve --- + console.log('6. admin resolves the request...'); + const resolved = await admin.maintenanceRequest.resolve({ + id: created.id, + resolutionNote: 'Peça trocada', + }); + if (resolved.status !== 'RESOLVED') throw new Error(`Expected RESOLVED, got ${resolved.status}`); + console.log(` status=${resolved.status} resolvedBy=${resolved.resolvedBy?.email} ✓`); + + // --- queue order --- + console.log('7. Creating two more requests to verify queue order...'); + const r2 = await op1.maintenanceRequest.create({ + workstationId: ws.id, + description: 'Segundo pedido smoke test ABC', + clientRequestId: crypto.randomUUID(), + }); + const r3 = await op1.maintenanceRequest.create({ + workstationId: ws.id, + description: 'Terceiro pedido smoke test XYZ', + clientRequestId: crypto.randomUUID(), + }); + + const queue = await admin.maintenanceRequest.queue({ statuses: ['OPEN'], limit: 10 }); + const ids = queue.items.map((i) => i.id); + const r3Index = ids.indexOf(r3.id); + const r2Index = ids.indexOf(r2.id); + if (r3Index === -1 || r2Index === -1) throw new Error('Requests not in queue'); + if (r3Index > r2Index) throw new Error('Queue not ordered by createdAt DESC'); + console.log(` Queue order DESC: r3 at pos ${r3Index}, r2 at pos ${r2Index} ✓`); + + // --- DomainEvents --- + console.log('8. Verifying DomainEvents were emitted...'); + const events = await prisma.domainEvent.findMany({ + where: { aggregateId: created.id }, + orderBy: { occurredAt: 'asc' }, + }); + const types = events.map((e) => e.eventType); + if (JSON.stringify(types) !== JSON.stringify(['created', 'claimed', 'resolved'])) { + throw new Error(`Unexpected event types: ${JSON.stringify(types)}`); + } + console.log(` Events: ${types.join(' → ')} ✓`); + + await prisma.$disconnect(); + console.log('\nMaintenanceRequest router AC PASSED'); +} + +main().catch(async (err) => { + console.error('MaintenanceRequest router AC FAILED:', err); + await prisma.$disconnect(); + process.exit(1); +});