Assemble QMS app + SQLite refactor + Unraid single-container deploy
Build and Push Docker Image / build (push) Successful in 1m12s

Reconstruct the full app from init-source overlays (base + fix-1..6 +
update-1..3, last-wins) at the repo root, complete the missing pieces so it
builds and runs, and stage the Unraid deployment.

App completion:
- types/index.ts: former Prisma enums as string-literal unions + AppUser
- pages/_app.tsx + styles/globals.css (mount AppProvider/ToastProvider)
- API routes: auth/login, auth/me, users, submissions (+REVIEW_READY notify),
  forms (list/create), notifications
- scripts/create-admin.js: idempotent first-admin bootstrap
- 14 unbuilt nav targets stubbed via ComingSoon placeholder

SQLite refactor (single-container, no external DB):
- schema provider -> sqlite; enums -> String; Json -> String;
  FormField.options String[] -> JSON-encoded String
- lib/forms.ts (de)serialises options at the DB boundary
- drop mode:"insensitive" (unsupported on SQLite)
- enum imports repointed from @prisma/client to @/types

Deploy:
- multi-stage Dockerfile (next build -> prod runner), docker-entrypoint.sh
  (prisma db push -> create-admin -> next start), .dockerignore
- docker-compose.yml: br0 10.2.0.x, /mnt/user/appdata/qms -> /data volume
- README rewritten for the Unraid/Gitea Actions flow; .env scrubbed of the
  live Supabase credential; vercel.json removed

Verified: next build clean (41 routes); live SQLite round-trip of
login/session, form options array, and submission -> REVIEW_READY.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jason
2026-06-15 16:57:15 -05:00
parent 631890c5bd
commit ad499f6782
70 changed files with 8045 additions and 0 deletions
+431
View File
@@ -0,0 +1,431 @@
// V11 Enterprise QMS — SQLite schema
// NOTE: SQLite (Prisma connector) supports neither native enums nor the Json type.
// All former enums are stored as String (validated in app code; union types live in @/types).
// All former Json columns are stored as String (JSON-encoded; (de)serialised in app code).
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
// ─── USERS & AUTH ─────────────────────────────────────────────────────────────
// Role: ADMIN | QC | PRODUCTION | PRODUCTION_LEAD | LOGISTICS_LEAD | MANAGEMENT
model User {
id String @id @default(cuid())
email String @unique
name String
password String
role String @default("PRODUCTION")
department String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
capasOwned CAPA[] @relation("CAPAOwner")
capasRaised CAPA[] @relation("CAPARaisedBy")
auditsLed Audit[] @relation("AuditLead")
ncrsRaised NCR[] @relation("NCRRaisedBy")
submissions FormSubmission[]
auditLogs AuditLog[]
notifications Notification[]
}
model Session {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
// ─── AUDIT TRAIL ──────────────────────────────────────────────────────────────
model AuditLog {
id String @id @default(cuid())
userId String
action String
entity String
entityId String
before String? // JSON-encoded
after String? // JSON-encoded
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@index([entity, entityId])
@@index([userId])
@@index([createdAt])
}
// ─── NOTIFICATIONS ────────────────────────────────────────────────────────────
// type: CAPA_OVERDUE | CAPA_ASSIGNED | AUDIT_DUE | NCR_ESCALATED |
// FORM_REVIEW_READY | DOC_EXPIRING | STANDARD_APPROVED | SOLUTION_CONFIRMED
model Notification {
id String @id @default(cuid())
userId String
type String
title String
body String
read Boolean @default(false)
link String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, read])
}
// ─── CAPA ─────────────────────────────────────────────────────────────────────
// priority: CRITICAL | HIGH | MEDIUM | LOW
// status: OPEN | IN_PROGRESS | OVERDUE | CLOSED
model CAPA {
id String @id @default(cuid())
ref String @unique
title String
description String?
priority String @default("MEDIUM")
status String @default("OPEN")
progress Int @default(0)
ownerId String
raisedById String
dueDate DateTime
closedAt DateTime?
rootCause String?
corrAction String?
prevAction String?
department String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation("CAPAOwner", fields: [ownerId], references: [id])
raisedBy User @relation("CAPARaisedBy", fields: [raisedById], references: [id])
timeline CAPAEvent[]
ncrs NCR[]
escapes QualityEscape[]
@@index([status])
@@index([ownerId])
@@index([dueDate])
}
model CAPAEvent {
id String @id @default(cuid())
capaId String
event String
note String?
createdAt DateTime @default(now())
capa CAPA @relation(fields: [capaId], references: [id], onDelete: Cascade)
@@index([capaId])
}
// ─── AUDITS ───────────────────────────────────────────────────────────────────
// type: INTERNAL | SUPPLIER | EXTERNAL
// status: SCHEDULED | IN_PROGRESS | COMPLETED | CANCELLED
model Audit {
id String @id @default(cuid())
ref String @unique
name String
type String @default("INTERNAL")
status String @default("SCHEDULED")
leadId String
scope String?
scheduledAt DateTime
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lead User @relation("AuditLead", fields: [leadId], references: [id])
findings Finding[]
@@index([status])
@@index([scheduledAt])
}
// severity: OBSERVATION | MINOR | MAJOR | CRITICAL
// status: OPEN | IN_PROGRESS | CLOSED
model Finding {
id String @id @default(cuid())
auditId String
description String
severity String @default("MINOR")
status String @default("OPEN")
category String?
dueDate DateTime?
closedAt DateTime?
createdAt DateTime @default(now())
audit Audit @relation(fields: [auditId], references: [id], onDelete: Cascade)
@@index([auditId])
}
// ─── NCR ──────────────────────────────────────────────────────────────────────
// severity: OBSERVATION | MINOR | MAJOR (null = needs triage)
// status: OPEN | INVESTIGATING | ESCALATED | RESOLVED
model NCR {
id String @id @default(cuid())
ref String @unique
description String
source String?
severity String?
status String @default("OPEN")
raisedById String
resolvedAt DateTime?
resolution String?
category String?
notified Boolean @default(false)
notifiedAt DateTime?
capaId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
raisedBy User @relation("NCRRaisedBy", fields: [raisedById], references: [id])
capa CAPA? @relation(fields: [capaId], references: [id])
@@index([status])
@@index([createdAt])
}
// ─── RESOLUTIONS LIBRARY ──────────────────────────────────────────────────────
// Filed whenever an NCR or quality escape is resolved with a category.
// Searched for "similar past fix" matching on new issues.
model Resolution {
id String @id @default(cuid())
title String
category String
resolution String
linkedRef String
createdAt DateTime @default(now())
@@index([category])
}
// ─── DOCUMENTS ────────────────────────────────────────────────────────────────
// status: CURRENT | PENDING_REVIEW | EXPIRED | ARCHIVED
// category: SOP | POLICY | FORM | WORK_INSTRUCTION | RECORD
model Document {
id String @id @default(cuid())
ref String @unique
title String
category String @default("SOP")
revision String @default("A")
status String @default("CURRENT")
fileUrl String?
ownerId String?
reviewDate DateTime?
approvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([category])
}
// ─── RISK REGISTER ────────────────────────────────────────────────────────────
// level: LOW | MEDIUM | HIGH | CRITICAL
model Risk {
id String @id @default(cuid())
title String
description String?
likelihood Int @default(1)
impact Int @default(1)
score Int @default(1)
level String @default("LOW")
owner String?
controls String?
reviewDate DateTime?
accepted Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([level])
}
// ─── SUPPLIERS ────────────────────────────────────────────────────────────────
// status: APPROVED | UNDER_REVIEW | SUSPENDED | INACTIVE
// tier: TIER_1 | TIER_2 | TIER_3
model Supplier {
id String @id @default(cuid())
name String
category String?
tier String @default("TIER_2")
status String @default("UNDER_REVIEW")
score Float?
contact String?
email String?
lastAudit DateTime?
nextAudit DateTime?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
}
// ─── FIRST BUILD FORMS ────────────────────────────────────────────────────────
// FieldType: SHORT_TEXT | LONG_TEXT | NUMBER | DATE | SINGLE_CHOICE | MULTI_CHOICE | RATING | PHOTO
// FormStatus: DRAFT | ACTIVE | SUSPENDED | REVIEW_READY | STANDARD_SET | ARCHIVED
model BuildForm {
id String @id @default(cuid())
name String
product String?
description String?
status String @default("DRAFT")
minSubmissions Int @default(10)
createdById String?
publishedAt DateTime?
suspendedAt DateTime?
archivedAt DateTime?
clonedFromId String?
clonedFromName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fields FormField[]
submissions FormSubmission[]
standard QualityStandard?
@@index([status])
}
model FormField {
id String @id @default(cuid())
formId String
label String
type String @default("SHORT_TEXT")
hint String?
options String @default("[]") // JSON-encoded string[] (SQLite has no scalar lists)
required Boolean @default(false)
trackStd Boolean @default(true)
order Int @default(0)
form BuildForm @relation(fields: [formId], references: [id], onDelete: Cascade)
@@index([formId])
}
model FormSubmission {
id String @id @default(cuid())
formId String
submittedBy String
data String // JSON-encoded answers
createdAt DateTime @default(now())
form BuildForm @relation(fields: [formId], references: [id])
user User @relation(fields: [submittedBy], references: [id])
@@index([formId])
@@index([createdAt])
}
model QualityStandard {
id String @id @default(cuid())
formId String @unique
title String
specs String // JSON-encoded
status String @default("DRAFT")
approvedBy String?
approvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
form BuildForm @relation(fields: [formId], references: [id])
}
// ─── SETTINGS ─────────────────────────────────────────────────────────────────
model Setting {
key String @id
value String
updatedAt DateTime @updatedAt
}
// ─── CLIENT RELEASE / SHIPMENTS ────────────────────────────────────────────────
// "Good status" packages — grouped by product/batch/date — that get
// batch-emailed to clients once everything has passed QC.
// Send access: PRODUCTION_LEAD, LOGISTICS_LEAD, ADMIN.
// ShipmentItem.type: FORM_DATA | NCR_FIX | AUDIT | OTHER
model Shipment {
id String @id @default(cuid())
ref String @unique
product String
batch String
client String
clientEmail String?
shippedAt DateTime
sentAt DateTime?
sentTo String?
createdById String?
createdAt DateTime @default(now())
items ShipmentItem[]
escapes QualityEscape[]
@@index([product, batch])
}
model ShipmentItem {
id String @id @default(cuid())
shipmentId String
label String
type String @default("OTHER")
included Boolean @default(true)
order Int @default(0)
shipment Shipment @relation(fields: [shipmentId], references: [id], onDelete: Cascade)
@@index([shipmentId])
}
// ─── CLIENT ISSUES (QUALITY ESCAPES) ──────────────────────────────────────────
// A defect that passed QC and reached the client. Reuses the NCR
// investigation/resolution flow, but linked to a shipment, and resolving
// one can add an entry to the living shipping standard.
// severity: OBSERVATION | MINOR | MAJOR (null = needs triage)
// status: OPEN | INVESTIGATING | RESOLVED | ESCALATED
model QualityEscape {
id String @id @default(cuid())
ref String @unique
shipmentId String
description String
contact String?
severity String?
status String @default("OPEN")
resolution String?
category String?
capaId String?
standardItemAdded String?
createdAt DateTime @default(now())
resolvedAt DateTime?
shipment Shipment @relation(fields: [shipmentId], references: [id])
capa CAPA? @relation(fields: [capaId], references: [id])
@@index([status])
}
// ─── LIVING SHIPPING STANDARD ─────────────────────────────────────────────────
// What must be true before a product gets "good status". Baseline items
// plus items added automatically when a quality escape is resolved.
model ShippingStandardItem {
id String @id @default(cuid())
text String
source String @default("Baseline")
order Int @default(0)
createdAt DateTime @default(now())
}