MAI CALL - step 7
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user