MY QUALITY - First iteration
This commit is contained in:
@@ -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,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 };
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user