MY QUALITY - First iteration

This commit is contained in:
2026-06-11 15:43:35 +01:00
parent 4f8996712e
commit 1fdb9536fa
31 changed files with 1965 additions and 98 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ import { logger } from './logger';
export type SessionUser = {
id: string;
email: string;
role: 'ADMIN' | 'SUPERVISOR' | 'OPERATOR';
role: 'ADMIN' | 'SUPERVISOR' | 'QUALITY' | 'OPERATOR';
tenantId: string;
};
+4
View File
@@ -4,6 +4,8 @@ import { workstationRouter } from './workstation';
import { userRouter } from './user';
import { storageRouter } from './storage';
import { maintenanceRequestRouter } from './maintenance-request';
import { operatorSessionRouter } from './operator-session';
import { qualityDefectRouter } from './quality-defect';
export const appRouter = router({
ping: pingRouter,
@@ -11,6 +13,8 @@ export const appRouter = router({
user: userRouter,
storage: storageRouter,
maintenanceRequest: maintenanceRequestRouter,
operatorSession: operatorSessionRouter,
qualityDefect: qualityDefectRouter,
});
export type AppRouter = typeof appRouter;
@@ -0,0 +1,53 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { protectedProcedure, router } from '../trpc';
const SESSION_INCLUDE = {
workstation: { select: { id: true, code: true, name: true, area: true } },
} as const;
// Prisma's interactive-transaction client does not support $extends, so
// tenantId is injected manually into each where/data clause inside $transaction.
export const operatorSessionRouter = router({
/** The authenticated user's active session (badge-in), or null if not badged in. */
current: protectedProcedure.query(({ ctx }) => {
return ctx.db.operatorSession.findFirst({
where: { userId: ctx.user.id, endedAt: null },
include: SESSION_INCLUDE,
orderBy: { startedAt: 'desc' },
});
}),
/** Badge-in at a workstation. Ends any previous active session for this user. */
start: protectedProcedure
.input(z.object({ workstationId: z.string().cuid() }))
.mutation(async ({ ctx, input }) => {
const tid = ctx.tenantId;
const ws = await ctx.db.workstation.findFirst({
where: { id: input.workstationId },
select: { id: true },
});
if (!ws) throw new TRPCError({ code: 'NOT_FOUND', message: 'Workstation not found.' });
return ctx.prisma.$transaction(async (tx) => {
await tx.operatorSession.updateMany({
where: { tenantId: tid, userId: ctx.user.id, endedAt: null },
data: { endedAt: new Date() },
});
return tx.operatorSession.create({
data: { tenantId: tid, userId: ctx.user.id, workstationId: input.workstationId },
include: SESSION_INCLUDE,
});
});
}),
/** Badge-out: end the active session, if any. Idempotent. */
end: protectedProcedure.mutation(async ({ ctx }) => {
await ctx.db.operatorSession.updateMany({
where: { userId: ctx.user.id, endedAt: null },
data: { endedAt: new Date() },
});
return { ok: true };
}),
});
+179
View File
@@ -0,0 +1,179 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { protectedProcedure, requireRole, router } from '../trpc';
const photoKeySchema = z
.string()
.regex(/^tenants\/[a-z0-9-]+\/quality\/[a-z0-9-]+\.(jpg|jpeg|png|webp)$/);
const statusSchema = z.enum(['OPEN', 'ACKNOWLEDGED', 'CORRECTED']);
const DEFECT_INCLUDE = {
workstation: { select: { id: true, code: true, name: true, area: true } },
createdBy: { select: { id: true, email: true } },
acknowledgedBy: { select: { id: true, email: true } },
correctedBy: { select: { id: true, email: true } },
} as const;
// Prisma's interactive-transaction client does not support $extends, so
// tenantId is injected manually into each where/data clause inside $transaction.
export const qualityDefectRouter = router({
/** QCP raises a defect against a workstation. */
create: requireRole('QUALITY', 'ADMIN')
.input(
z.object({
workstationId: z.string().cuid(),
defectType: z.string().trim().min(1).max(100),
location: z.string().trim().max(200).optional(),
description: z.string().trim().min(3).max(1000),
rfsCode: z.string().trim().max(100).optional(),
photoKey: photoKeySchema.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const tid = ctx.tenantId;
return ctx.prisma.$transaction(async (tx) => {
const defect = await tx.qualityDefect.create({
data: {
tenantId: tid,
workstationId: input.workstationId,
createdByUserId: ctx.user.id,
defectType: input.defectType,
location: input.location,
description: input.description,
rfsCode: input.rfsCode,
photoKey: input.photoKey,
},
include: DEFECT_INCLUDE,
});
await tx.domainEvent.create({
data: {
tenantId: tid,
aggregateType: 'QualityDefect',
aggregateId: defect.id,
eventType: 'created',
payload: { workstationId: input.workstationId, createdByUserId: ctx.user.id },
},
});
return defect;
});
}),
/** QCP / admin queue of defects across the plant. */
queue: requireRole('QUALITY', 'ADMIN', 'SUPERVISOR')
.input(
z.object({
statuses: z.array(statusSchema).optional(),
limit: z.number().int().min(1).max(100).default(50),
}),
)
.query(({ ctx, input }) => {
return ctx.db.qualityDefect.findMany({
where: { ...(input.statuses?.length ? { status: { in: input.statuses } } : {}) },
include: DEFECT_INCLUDE,
orderBy: { createdAt: 'desc' },
take: input.limit,
});
}),
/** Operator: defects routed to my active session's workstation. Empty if not badged in. */
forMyStation: protectedProcedure
.input(z.object({ statuses: z.array(statusSchema).optional() }).optional())
.query(async ({ ctx, input }) => {
const session = await ctx.db.operatorSession.findFirst({
where: { userId: ctx.user.id, endedAt: null },
orderBy: { startedAt: 'desc' },
select: { workstationId: true },
});
if (!session) return [];
const statuses = input?.statuses ?? (['OPEN', 'ACKNOWLEDGED'] as const);
return ctx.db.qualityDefect.findMany({
where: { workstationId: session.workstationId, status: { in: [...statuses] } },
include: DEFECT_INCLUDE,
orderBy: { createdAt: 'desc' },
});
}),
/** Operator acknowledges a defect (OPEN -> ACKNOWLEDGED). */
acknowledge: protectedProcedure
.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.qualityDefect.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 acknowledge: status is ${existing.status}, expected OPEN.`,
});
}
const updated = await tx.qualityDefect.update({
where: { id: input.id },
data: {
status: 'ACKNOWLEDGED',
acknowledgedByUserId: ctx.user.id,
acknowledgedAt: new Date(),
},
include: DEFECT_INCLUDE,
});
await tx.domainEvent.create({
data: {
tenantId: tid,
aggregateType: 'QualityDefect',
aggregateId: input.id,
eventType: 'acknowledged',
payload: { acknowledgedByUserId: ctx.user.id },
},
});
return updated;
});
}),
/** Operator marks a defect corrected (ACKNOWLEDGED -> CORRECTED). */
correct: protectedProcedure
.input(z.object({ id: z.string().cuid(), correctionNote: z.string().trim().max(2000).optional() }))
.mutation(async ({ ctx, input }) => {
const tid = ctx.tenantId;
return ctx.prisma.$transaction(async (tx) => {
const existing = await tx.qualityDefect.findFirst({
where: { id: input.id, tenantId: tid },
select: { status: true },
});
if (!existing) throw new TRPCError({ code: 'NOT_FOUND' });
if (existing.status !== 'ACKNOWLEDGED') {
throw new TRPCError({
code: 'CONFLICT',
message: `Cannot correct: status is ${existing.status}, expected ACKNOWLEDGED.`,
});
}
const updated = await tx.qualityDefect.update({
where: { id: input.id },
data: {
status: 'CORRECTED',
correctedByUserId: ctx.user.id,
correctedAt: new Date(),
correctionNote: input.correctionNote,
},
include: DEFECT_INCLUDE,
});
await tx.domainEvent.create({
data: {
tenantId: tid,
aggregateType: 'QualityDefect',
aggregateId: input.id,
eventType: 'corrected',
payload: {
correctedByUserId: ctx.user.id,
correctionNote: input.correctionNote ?? null,
},
},
});
return updated;
});
}),
});
+5 -2
View File
@@ -12,7 +12,7 @@ const EXT_MAP: Record<(typeof CONTENT_TYPES)[number], string> = {
const photoKeySchema = z
.string()
.regex(/^tenants\/[a-z0-9-]+\/maintenance\/[a-z0-9-]+\.(jpg|jpeg|png|webp)$/);
.regex(/^tenants\/[a-z0-9-]+\/(maintenance|quality)\/[a-z0-9-]+\.(jpg|jpeg|png|webp)$/);
export const storageRouter = router({
signPhotoUpload: protectedProcedure
@@ -20,11 +20,14 @@ export const storageRouter = router({
z.object({
contentType: z.enum(CONTENT_TYPES),
byteSize: z.number().int().min(1).max(10 * 1024 * 1024),
// Logical bucket inside the tenant prefix. Defaults to 'maintenance' so
// existing MAI CALL callers need no change; MY QUALITY uses 'quality'.
category: z.enum(['maintenance', 'quality']).default('maintenance'),
}),
)
.mutation(async ({ ctx, input }) => {
const ext = EXT_MAP[input.contentType];
const photoKey = `tenants/${ctx.tenantId}/maintenance/${randomUUID()}.${ext}`;
const photoKey = `tenants/${ctx.tenantId}/${input.category}/${randomUUID()}.${ext}`;
const storage = makeStorage();
const { url: uploadUrl, expiresAt } = await storage.signPut(
photoKey,