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,
@@ -0,0 +1,78 @@
-- CreateEnum
CREATE TYPE "QualityDefectStatus" AS ENUM ('OPEN', 'ACKNOWLEDGED', 'CORRECTED');
-- AlterEnum
ALTER TYPE "UserRole" ADD VALUE 'QUALITY';
-- CreateTable
CREATE TABLE "OperatorSession" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"workstationId" TEXT NOT NULL,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"endedAt" TIMESTAMP(3),
CONSTRAINT "OperatorSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "QualityDefect" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"workstationId" TEXT NOT NULL,
"createdByUserId" TEXT NOT NULL,
"defectType" TEXT NOT NULL,
"location" TEXT,
"description" TEXT NOT NULL,
"rfsCode" TEXT,
"photoKey" TEXT,
"status" "QualityDefectStatus" NOT NULL DEFAULT 'OPEN',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"acknowledgedByUserId" TEXT,
"acknowledgedAt" TIMESTAMP(3),
"correctedByUserId" TEXT,
"correctedAt" TIMESTAMP(3),
"correctionNote" TEXT,
CONSTRAINT "QualityDefect_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "OperatorSession_tenantId_idx" ON "OperatorSession"("tenantId");
-- CreateIndex
CREATE INDEX "OperatorSession_tenantId_userId_endedAt_idx" ON "OperatorSession"("tenantId", "userId", "endedAt");
-- CreateIndex
CREATE INDEX "OperatorSession_tenantId_workstationId_endedAt_idx" ON "OperatorSession"("tenantId", "workstationId", "endedAt");
-- CreateIndex
CREATE INDEX "QualityDefect_tenantId_status_createdAt_idx" ON "QualityDefect"("tenantId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "QualityDefect_tenantId_workstationId_status_idx" ON "QualityDefect"("tenantId", "workstationId", "status");
-- AddForeignKey
ALTER TABLE "OperatorSession" ADD CONSTRAINT "OperatorSession_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OperatorSession" ADD CONSTRAINT "OperatorSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OperatorSession" ADD CONSTRAINT "OperatorSession_workstationId_fkey" FOREIGN KEY ("workstationId") REFERENCES "Workstation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QualityDefect" ADD CONSTRAINT "QualityDefect_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QualityDefect" ADD CONSTRAINT "QualityDefect_workstationId_fkey" FOREIGN KEY ("workstationId") REFERENCES "Workstation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QualityDefect" ADD CONSTRAINT "QualityDefect_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QualityDefect" ADD CONSTRAINT "QualityDefect_acknowledgedByUserId_fkey" FOREIGN KEY ("acknowledgedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QualityDefect" ADD CONSTRAINT "QualityDefect_correctedByUserId_fkey" FOREIGN KEY ("correctedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+71
View File
@@ -16,6 +16,7 @@ datasource db {
enum UserRole {
ADMIN
SUPERVISOR
QUALITY
OPERATOR
}
@@ -25,6 +26,12 @@ enum MaintenanceRequestStatus {
RESOLVED
}
enum QualityDefectStatus {
OPEN
ACKNOWLEDGED
CORRECTED
}
model Tenant {
id String @id @default(cuid())
name String
@@ -34,6 +41,8 @@ model Tenant {
workstations Workstation[]
events DomainEvent[]
maintenanceRequests MaintenanceRequest[]
operatorSessions OperatorSession[]
qualityDefects QualityDefect[]
}
model User {
@@ -52,6 +61,11 @@ model User {
claimedRequests MaintenanceRequest[] @relation("claimed")
resolvedRequests MaintenanceRequest[] @relation("resolved")
sessions OperatorSession[]
createdDefects QualityDefect[] @relation("defectCreated")
acknowledgedDefects QualityDefect[] @relation("defectAcknowledged")
correctedDefects QualityDefect[] @relation("defectCorrected")
@@unique([tenantId, email])
@@index([tenantId])
}
@@ -65,6 +79,8 @@ model Workstation {
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
maintenanceRequests MaintenanceRequest[]
operatorSessions OperatorSession[]
qualityDefects QualityDefect[]
@@unique([tenantId, code])
@@index([tenantId])
@@ -115,3 +131,58 @@ model MaintenanceRequest {
@@index([tenantId, status, createdAt])
@@index([tenantId, reportedByUserId])
}
/// MY QUALITY — an operator's active binding to a workstation ("badge-in").
/// At most one active session (endedAt == null) per user; starting a new one
/// ends the previous. Quality defects route to whoever has the active session
/// at the targeted workstation.
model OperatorSession {
id String @id @default(cuid())
tenantId String
userId String
workstationId String
startedAt DateTime @default(now())
endedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
workstation Workstation @relation(fields: [workstationId], references: [id])
@@index([tenantId])
@@index([tenantId, userId, endedAt])
@@index([tenantId, workstationId, endedAt])
}
/// MY QUALITY — a quality defect raised by QCP against a workstation, routed to
/// the operator currently bound there. Mirrors MaintenanceRequest but in the
/// opposite direction (quality -> operator). State: OPEN -> ACKNOWLEDGED ->
/// CORRECTED.
model QualityDefect {
id String @id @default(cuid())
tenantId String
workstationId String
createdByUserId String
defectType String
location String?
description String
rfsCode String?
photoKey String?
status QualityDefectStatus @default(OPEN)
createdAt DateTime @default(now())
acknowledgedByUserId String?
acknowledgedAt DateTime?
correctedByUserId String?
correctedAt DateTime?
correctionNote String?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
workstation Workstation @relation(fields: [workstationId], references: [id])
createdBy User @relation("defectCreated", fields: [createdByUserId], references: [id])
acknowledgedBy User? @relation("defectAcknowledged", fields: [acknowledgedByUserId], references: [id])
correctedBy User? @relation("defectCorrected", fields: [correctedByUserId], references: [id])
@@index([tenantId, status, createdAt])
@@index([tenantId, workstationId, status])
}
+85
View File
@@ -15,6 +15,8 @@ const prisma = new PrismaClient();
const DEMO_TENANT_NAME = 'Demo Factory';
const DEMO_ADMIN_EMAIL = 'admin@demo.local';
const DEMO_ADMIN_PASSWORD = 'admin1234';
const DEMO_QCP_EMAIL = 'qcp@demo.local';
const DEMO_QCP_PASSWORD = 'qcp1234';
const OPERATORS = [
{ email: 'op1@demo.local', pin: '1111' },
@@ -49,6 +51,15 @@ async function main() {
},
});
await prisma.user.create({
data: {
tenantId: tenant.id,
email: DEMO_QCP_EMAIL,
role: UserRole.QUALITY,
passwordHash: await hashSecret(DEMO_QCP_PASSWORD),
},
});
for (const op of OPERATORS) {
await prisma.user.create({
data: {
@@ -161,6 +172,77 @@ async function main() {
}
console.warn(` pedidos de exemplo: ${samples.length} criados`);
// MY QUALITY — op1 is badged-in at the first workstation, and QCP has
// raised a few defects there so the operator's alerts and the QCP queue
// are non-empty on first boot.
const station = wsList[0]!;
const qcpUser = await prisma.user.findFirst({
where: { tenantId: tenant.id, email: DEMO_QCP_EMAIL },
});
await prisma.operatorSession.create({
data: {
tenantId: tenant.id,
userId: op1User.id,
workstationId: station.id,
startedAt: ago(120),
},
});
if (qcpUser) {
const defects = [
// OPEN — operator hasn't seen it yet
{
tenantId: tenant.id,
workstationId: station.id,
createdByUserId: qcpUser.id,
defectType: 'Aperto não conforme',
location: 'Banco dianteiro esquerdo',
description: 'Binário fora de especificação no parafuso da calha.',
rfsCode: 'RFS-1042',
status: 'OPEN' as const,
createdAt: ago(8),
},
// ACKNOWLEDGED — operator saw it, correcting
{
tenantId: tenant.id,
workstationId: station.id,
createdByUserId: qcpUser.id,
defectType: 'Clip em falta',
location: 'Painel de porta traseira direita',
description: 'Clip de fixação do painel ausente.',
rfsCode: 'RFS-1043',
status: 'ACKNOWLEDGED' as const,
createdAt: ago(35),
acknowledgedByUserId: op1User.id,
acknowledgedAt: ago(30),
},
// CORRECTED — closed loop
{
tenantId: tenant.id,
workstationId: station.id,
createdByUserId: qcpUser.id,
defectType: 'Risco na pintura',
location: 'Capot',
description: 'Risco superficial detetado no controlo visual.',
rfsCode: 'RFS-1041',
status: 'CORRECTED' as const,
createdAt: ago(90),
acknowledgedByUserId: op1User.id,
acknowledgedAt: ago(85),
correctedByUserId: op1User.id,
correctedAt: ago(70),
correctionNote: 'Polimento efetuado, defeito eliminado.',
},
];
for (const d of defects) {
await prisma.qualityDefect.create({ data: d });
}
console.warn(` defeitos de exemplo: ${defects.length} criados (op1 em ${station.code})`);
}
}
console.warn(
@@ -169,6 +251,9 @@ async function main() {
console.warn(
` admin: ${DEMO_ADMIN_EMAIL} / ${DEMO_ADMIN_PASSWORD}`,
);
console.warn(
` qcp: ${DEMO_QCP_EMAIL} / ${DEMO_QCP_PASSWORD}`,
);
console.warn(
` operadores: ${OPERATORS.map((o) => `${o.email}=${o.pin}`).join(' | ')}`,
);
+10 -2
View File
@@ -1,5 +1,13 @@
export { prisma, type DbClient } from './client';
export { tenantScoped, type TenantScopedClient } from './tenant-extension';
export { Prisma, UserRole, MaintenanceRequestStatus } from '@prisma/client';
export type { User, Tenant, Workstation, DomainEvent, MaintenanceRequest } from '@prisma/client';
export { Prisma, UserRole, MaintenanceRequestStatus, QualityDefectStatus } from '@prisma/client';
export type {
User,
Tenant,
Workstation,
DomainEvent,
MaintenanceRequest,
OperatorSession,
QualityDefect,
} from '@prisma/client';
export { hashSecret, verifySecret } from './crypto';
+8 -1
View File
@@ -83,7 +83,14 @@ import type { PrismaClient } from '@prisma/client';
* ============================================================================
*/
const TENANT_SCOPED_MODELS = ['User', 'Workstation', 'DomainEvent', 'MaintenanceRequest'] as const;
const TENANT_SCOPED_MODELS = [
'User',
'Workstation',
'DomainEvent',
'MaintenanceRequest',
'OperatorSession',
'QualityDefect',
] as const;
type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
function isTenantScoped(model: string | undefined): model is TenantScopedModel {
+1
View File
@@ -18,6 +18,7 @@
},
"devDependencies": {
"@repo/config": "workspace:*",
"@types/node": "22.19.19",
"rimraf": "^6.0.1",
"typescript": "^5.7.2"
}
+6 -3
View File
@@ -1,6 +1,9 @@
{
"extends": "@repo/config/tsconfig/library.json",
"extends": "@repo/config/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
}
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["src/**/*.ts"]
}