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
+26
View File
@@ -0,0 +1,26 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { verifyPassword, createSession, SESSION_COOKIE, SESSION_EXPIRY_DAYS } from '@/lib/auth'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end()
const { email, password } = req.body || {}
if (!email || !password) return res.status(400).json({ error: 'Email and password are required' })
const user = await prisma.user.findUnique({ where: { email: String(email).trim().toLowerCase() } })
if (!user || !user.active) return res.status(401).json({ error: 'Invalid email or password' })
const ok = await verifyPassword(password, user.password)
if (!ok) return res.status(401).json({ error: 'Invalid email or password' })
const token = await createSession(user.id)
const maxAge = SESSION_EXPIRY_DAYS * 24 * 60 * 60
res.setHeader(
'Set-Cookie',
`${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`
)
const { password: _pw, ...safe } = user
return res.json({ user: safe })
}
+23
View File
@@ -0,0 +1,23 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { getSessionUser, SESSION_COOKIE } from '@/lib/auth'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
const user = await getSessionUser(req)
if (!user) return res.status(401).json({ user: null })
const { password: _pw, ...safe } = user
return res.json({ user: safe })
}
if (req.method === 'DELETE') {
const token = req.cookies[SESSION_COOKIE]
if (token) {
await prisma.session.deleteMany({ where: { token } })
}
res.setHeader('Set-Cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`)
return res.json({ ok: true })
}
res.status(405).end()
}
+96
View File
@@ -0,0 +1,96 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction, generateRef } from '@/lib/auth'
import { NCRSeverity, EscapeStatus } from '@/types'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res, ['ADMIN', 'QC'])
if (!user) return
const { id } = req.query as { id: string }
const escape = await prisma.qualityEscape.findUnique({
where: { id },
include: { shipment: true, capa: { select: { ref: true } } },
})
if (!escape) return res.status(404).json({ error: 'Escape not found' })
if (req.method === 'GET') {
return res.json({ data: escape })
}
if (req.method === 'PATCH') {
const before = { ...escape }
const { severity, status, resolution, category, standardItem, escalate, capaForm } = req.body as {
severity?: NCRSeverity
status?: EscapeStatus
resolution?: string
category?: string
standardItem?: string
escalate?: boolean
capaForm?: { title: string; priority: string; ownerId: string; dueDate: string }
}
const updateData: any = {}
if (severity !== undefined) updateData.severity = severity
if (status !== undefined) updateData.status = status
if (resolution !== undefined && category !== undefined) {
updateData.resolution = resolution
updateData.category = category
updateData.status = 'RESOLVED'
updateData.resolvedAt = new Date()
}
if (standardItem !== undefined) {
updateData.standardItemAdded = standardItem || '—'
if (standardItem) {
const maxOrder = await prisma.shippingStandardItem.count()
await prisma.shippingStandardItem.create({
data: { text: standardItem, source: escape.ref, order: maxOrder },
})
}
}
let capaRef: string | undefined
if (escalate && capaForm) {
const count = await prisma.cAPA.count()
capaRef = generateRef('CAPA', count)
const capa = await prisma.cAPA.create({
data: {
ref: capaRef, title: capaForm.title,
description: `Client-reported quality escape ${escape.ref} (${escape.shipment.ref}): ${escape.description}`,
priority: capaForm.priority as any,
ownerId: capaForm.ownerId, raisedById: user.id,
dueDate: new Date(capaForm.dueDate),
},
})
await prisma.cAPAEvent.create({
data: { capaId: capa.id, event: 'CAPA raised', note: `Escalated from quality escape ${escape.ref} by ${user.name}` }
})
updateData.status = 'ESCALATED'
updateData.capaId = capa.id
}
const updated = await prisma.qualityEscape.update({
where: { id },
data: updateData,
include: { shipment: true, capa: { select: { ref: true } } },
})
// File to resolutions library
if (resolution !== undefined && category !== undefined) {
await prisma.resolution.create({
data: {
title: escape.description.length > 80 ? escape.description.slice(0, 80) + '…' : escape.description,
category, resolution, linkedRef: escape.ref,
}
})
}
await logAction(user.id, 'UPDATE', 'QualityEscape', id, before, updated)
return res.json({ data: updated })
}
res.status(405).end()
}
+47
View File
@@ -0,0 +1,47 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction, generateRef, SHIPMENT_SEND_ROLES } from '@/lib/auth'
import { EscapeStatus } from '@/types'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
if (req.method === 'GET') {
const { status } = req.query
const where: any = {}
if (status) where.status = status as EscapeStatus
const escapes = await prisma.qualityEscape.findMany({
where,
include: { shipment: true, capa: { select: { ref: true } } },
orderBy: { createdAt: 'desc' },
})
return res.json({ data: escapes })
}
if (req.method === 'POST') {
// Report access: Production leads, Logistics lead, Admin
const gated = await requireAuth(req, res, SHIPMENT_SEND_ROLES)
if (!gated) return
const { shipmentId, description, contact } = req.body
if (!shipmentId || !description) return res.status(400).json({ error: 'shipmentId and description required' })
const shipment = await prisma.shipment.findUnique({ where: { id: shipmentId } })
if (!shipment) return res.status(404).json({ error: 'Shipment not found' })
const count = await prisma.qualityEscape.count()
const ref = generateRef('ESC', count)
const escape = await prisma.qualityEscape.create({
data: { ref, shipmentId, description, contact },
include: { shipment: true },
})
await logAction(user.id, 'CREATE', 'QualityEscape', escape.id, null, { ref, shipmentRef: shipment.ref })
return res.status(201).json({ data: escape })
}
res.status(405).end()
}
+113
View File
@@ -0,0 +1,113 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction } from '@/lib/auth'
import { serializeOptions, withParsedFields } from '@/lib/forms'
import { FormStatus } from '@/types'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
const { id } = req.query as { id: string }
if (req.method === 'GET') {
const form = await prisma.buildForm.findUnique({
where: { id },
include: { fields: { orderBy: { order: 'asc' } }, _count: { select: { submissions: true, fields: true } } },
})
if (!form) return res.status(404).json({ error: 'Not found' })
return res.json({ data: withParsedFields(form) })
}
if (req.method === 'PATCH') {
if (user.role !== 'ADMIN') return res.status(403).json({ error: 'Admin only' })
const before = await prisma.buildForm.findUnique({ where: { id }, include: { fields: true } })
if (!before) return res.status(404).json({ error: 'Not found' })
const { fields, status, ...rest } = req.body as {
fields?: Array<{ id?: string; label: string; type: string; hint?: string; options?: string[]; required?: boolean; trackStd?: boolean }>
status?: FormStatus
name?: string; product?: string; description?: string; minSubmissions?: number; publishedAt?: string
}
// ── Full field-level edit (name/product/description/minSubmissions + fields array) ──
if (fields !== undefined) {
const existingIds = new Set(before.fields.map(f => f.id))
const keepIds = new Set(fields.filter(f => f.id && existingIds.has(f.id)).map(f => f.id as string))
const toDeleteIds = before.fields.filter(f => !keepIds.has(f.id)).map(f => f.id)
const ops: any[] = []
if (toDeleteIds.length > 0) {
ops.push(prisma.formField.deleteMany({ where: { id: { in: toDeleteIds } } }))
}
fields.forEach((f, i) => {
const data = {
label: f.label,
type: f.type as any,
hint: f.hint || null,
options: serializeOptions(f.options),
required: !!f.required,
trackStd: f.trackStd !== false,
order: i,
}
if (f.id && existingIds.has(f.id)) {
ops.push(prisma.formField.update({ where: { id: f.id }, data }))
} else {
ops.push(prisma.formField.create({ data: { ...data, formId: id } }))
}
})
const formUpdate: any = {}
if (rest.name !== undefined) formUpdate.name = rest.name
if (rest.product !== undefined) formUpdate.product = rest.product
if (rest.description !== undefined) formUpdate.description = rest.description
if (rest.minSubmissions !== undefined) formUpdate.minSubmissions = rest.minSubmissions
if (Object.keys(formUpdate).length > 0) {
ops.push(prisma.buildForm.update({ where: { id }, data: formUpdate }))
}
await prisma.$transaction(ops)
const updated = await prisma.buildForm.findUnique({
where: { id },
include: { fields: { orderBy: { order: 'asc' } }, _count: { select: { submissions: true, fields: true } } },
})
await logAction(user.id, 'UPDATE', 'BuildForm', id, before, updated)
return res.json({ data: withParsedFields(updated) })
}
// ── Status transitions (publish / suspend / reactivate / archive / restore) ──
const updateData: any = { ...rest }
if (status !== undefined) {
updateData.status = status
if (status === 'ACTIVE' && before.status === 'DRAFT') updateData.publishedAt = new Date()
if (status === 'ACTIVE' && before.status === 'SUSPENDED') updateData.suspendedAt = null
if (status === 'SUSPENDED') updateData.suspendedAt = new Date()
if (status === 'ARCHIVED') updateData.archivedAt = new Date()
if (status === 'DRAFT' && before.status === 'ARCHIVED') updateData.archivedAt = null
}
const updated = await prisma.buildForm.update({
where: { id },
data: updateData,
include: { fields: { orderBy: { order: 'asc' } }, _count: { select: { submissions: true, fields: true } } },
})
await logAction(user.id, 'UPDATE', 'BuildForm', id, { status: before.status }, { status: updated.status })
return res.json({ data: withParsedFields(updated) })
}
if (req.method === 'DELETE') {
if (user.role !== 'ADMIN') return res.status(403).json({ error: 'Admin only' })
await prisma.buildForm.delete({ where: { id } })
await logAction(user.id, 'DELETE', 'BuildForm', id, null, null)
return res.json({ ok: true })
}
res.status(405).end()
}
+50
View File
@@ -0,0 +1,50 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction } from '@/lib/auth'
import { withParsedFields } from '@/lib/forms'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res, ['ADMIN'])
if (!user) return
if (req.method !== 'POST') return res.status(405).end()
const { id } = req.query as { id: string }
const source = await prisma.buildForm.findUnique({
where: { id },
include: { fields: { orderBy: { order: 'asc' } } },
})
if (!source) return res.status(404).json({ error: 'Form not found' })
const clone = await prisma.buildForm.create({
data: {
name: `${source.name} (Copy)`,
product: source.product,
description: source.description,
minSubmissions: source.minSubmissions,
status: 'DRAFT',
createdById: user.id,
clonedFromId: source.id,
clonedFromName: source.name,
fields: {
create: source.fields.map(f => ({
label: f.label,
type: f.type,
hint: f.hint,
options: f.options,
required: f.required,
trackStd: f.trackStd,
order: f.order,
})),
},
},
include: {
fields: { orderBy: { order: 'asc' } },
_count: { select: { submissions: true, fields: true } },
},
})
await logAction(user.id, 'CREATE', 'BuildForm', clone.id, null, { clonedFrom: source.id, name: clone.name })
return res.status(201).json({ data: withParsedFields(clone) })
}
+71
View File
@@ -0,0 +1,71 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction } from '@/lib/auth'
import { serializeOptions, withParsedFields } from '@/lib/forms'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
if (req.method === 'GET') {
const { status } = req.query
const where: any = {}
if (status) where.status = String(status)
const forms = await prisma.buildForm.findMany({
where,
include: {
fields: { orderBy: { order: 'asc' } },
_count: { select: { submissions: true, fields: true } },
},
orderBy: { createdAt: 'desc' },
})
return res.json({ data: forms.map(f => withParsedFields(f)) })
}
if (req.method === 'POST') {
if (user.role !== 'ADMIN') return res.status(403).json({ error: 'Admin only' })
const { name, product, description, minSubmissions, fields } = req.body as {
name?: string
product?: string
description?: string
minSubmissions?: number
fields?: Array<{ label: string; type: string; hint?: string; options?: string[]; required?: boolean; trackStd?: boolean }>
}
if (!name) return res.status(400).json({ error: 'Form name is required' })
const form = await prisma.buildForm.create({
data: {
name,
product: product || null,
description: description || null,
minSubmissions: minSubmissions ?? 10,
status: 'DRAFT',
createdById: user.id,
fields: {
create: (fields || []).map((f, i) => ({
label: f.label,
type: f.type,
hint: f.hint || null,
options: serializeOptions(f.options),
required: !!f.required,
trackStd: f.trackStd !== false,
order: i,
})),
},
},
include: {
fields: { orderBy: { order: 'asc' } },
_count: { select: { submissions: true, fields: true } },
},
})
await logAction(user.id, 'CREATE', 'BuildForm', form.id, null, { name })
return res.status(201).json({ data: withParsedFields(form) })
}
res.status(405).end()
}
+78
View File
@@ -0,0 +1,78 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction } from '@/lib/auth'
import { notifySolutionConfirmed } from '@/lib/email'
import { NCRSeverity, NCRStatus } from '@/types'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res, ['ADMIN', 'QC'])
if (!user) return
const { id } = req.query as { id: string }
const ncr = await prisma.nCR.findUnique({
where: { id },
include: { raisedBy: { select: { id: true, name: true, email: true } }, capa: { select: { ref: true } } },
})
if (!ncr) return res.status(404).json({ error: 'NCR not found' })
if (req.method === 'GET') {
return res.json({ data: ncr })
}
if (req.method === 'PATCH') {
const before = { ...ncr }
const { severity, status, resolution, category, confirmNotify } = req.body as {
severity?: NCRSeverity
status?: NCRStatus
resolution?: string
category?: string
confirmNotify?: boolean
}
const updateData: any = {}
if (severity !== undefined) updateData.severity = severity
if (status !== undefined) updateData.status = status
if (resolution !== undefined && category !== undefined) {
// Resolving — requires both resolution notes and a category, files to library
updateData.resolution = resolution
updateData.category = category
updateData.status = 'RESOLVED'
updateData.resolvedAt = new Date()
}
if (confirmNotify) {
updateData.notified = true
updateData.notifiedAt = new Date()
}
const updated = await prisma.nCR.update({
where: { id },
data: updateData,
include: { raisedBy: { select: { id: true, name: true, email: true } }, capa: { select: { ref: true } } },
})
// File to resolutions library
if (resolution !== undefined && category !== undefined) {
await prisma.resolution.create({
data: {
title: ncr.description.length > 80 ? ncr.description.slice(0, 80) + '…' : ncr.description,
category, resolution, linkedRef: ncr.ref,
}
})
}
// Confirm-and-notify: emails/notifies whoever raised the NCR
if (confirmNotify && ncr.raisedBy) {
await notifySolutionConfirmed(ncr.ref, ncr.description, ncr.raisedBy.email, ncr.raisedBy.id)
}
await logAction(user.id, 'UPDATE', 'NCR', id, before, updated)
return res.json({ data: updated })
}
res.status(405).end()
}
+48
View File
@@ -0,0 +1,48 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction, generateRef } from '@/lib/auth'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res, ['ADMIN', 'QC'])
if (!user) return
if (req.method !== 'POST') return res.status(405).end()
const { id } = req.query as { id: string }
const { title, priority, ownerId, dueDate } = req.body
if (!title || !ownerId || !dueDate) {
return res.status(400).json({ error: 'title, ownerId, dueDate required' })
}
const ncr = await prisma.nCR.findUnique({ where: { id } })
if (!ncr) return res.status(404).json({ error: 'NCR not found' })
const count = await prisma.cAPA.count()
const ref = generateRef('CAPA', count)
const capa = await prisma.cAPA.create({
data: {
ref, title,
description: `Escalated from ${ncr.ref}: ${ncr.description}`,
priority: priority || 'MEDIUM',
ownerId, raisedById: user.id,
dueDate: new Date(dueDate),
},
include: { owner: { select: { id: true, name: true, email: true } } },
})
await prisma.cAPAEvent.create({
data: { capaId: capa.id, event: 'CAPA raised', note: `Escalated from ${ncr.ref} by ${user.name}` }
})
const updatedNcr = await prisma.nCR.update({
where: { id },
data: { status: 'ESCALATED', capaId: capa.id },
include: { raisedBy: { select: { name: true } }, capa: { select: { ref: true } } },
})
await logAction(user.id, 'UPDATE', 'NCR', id, { status: ncr.status }, { status: 'ESCALATED', capaRef: capa.ref })
await logAction(user.id, 'CREATE', 'CAPA', capa.id, null, { ref: capa.ref, fromNcr: ncr.ref })
return res.status(201).json({ data: { ncr: updatedNcr, capa } })
}
+41
View File
@@ -0,0 +1,41 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction, generateRef } from '@/lib/auth'
import { NCRStatus } from '@/types'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
if (req.method === 'GET') {
const { status } = req.query
const where: any = {}
if (status) where.status = status as NCRStatus
const ncrs = await prisma.nCR.findMany({
where,
include: { raisedBy: { select: { name: true } }, capa: { select: { ref: true } } },
orderBy: { createdAt: 'desc' },
})
return res.json({ data: ncrs })
}
if (req.method === 'POST') {
// Any authenticated user can report an issue (production intake)
const { description, source } = req.body
if (!description) return res.status(400).json({ error: 'description required' })
const count = await prisma.nCR.count()
const ref = generateRef('NCR', count)
const ncr = await prisma.nCR.create({
data: { ref, description, source, raisedById: user.id },
include: { raisedBy: { select: { name: true } } },
})
await logAction(user.id, 'CREATE', 'NCR', ncr.id, null, { ref, description })
return res.status(201).json({ data: ncr })
}
res.status(405).end()
}
+30
View File
@@ -0,0 +1,30 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth } from '@/lib/auth'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
if (req.method === 'GET') {
const [data, unread] = await Promise.all([
prisma.notification.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'desc' },
take: 20,
}),
prisma.notification.count({ where: { userId: user.id, read: false } }),
])
return res.json({ data, unread })
}
if (req.method === 'PATCH') {
await prisma.notification.updateMany({
where: { userId: user.id, read: false },
data: { read: true },
})
return res.json({ ok: true })
}
res.status(405).end()
}
+42
View File
@@ -0,0 +1,42 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth } from '@/lib/auth'
const STOPWORDS = new Set(['from', 'with', 'during', 'this', 'that', 'were', 'found', 'units', 'batch', 'final', 'client', 'reported'])
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
if (req.method !== 'GET') return res.status(405).end()
const { category, search, similarTo } = req.query
// Similar-fix matching: given a description, find resolutions sharing keywords
if (similarTo) {
const words = (similarTo as string)
.toLowerCase()
.match(/\b\w{4,}\b/g)?.filter(w => !STOPWORDS.has(w)) || []
if (words.length === 0) return res.json({ data: [] })
const all = await prisma.resolution.findMany({ orderBy: { createdAt: 'desc' } })
const matches = all.filter(r => {
const text = (r.title + ' ' + r.resolution).toLowerCase()
return words.some(w => text.includes(w))
})
return res.json({ data: matches.slice(0, 3) })
}
const where: any = {}
if (category) where.category = category as string
if (search) {
where.OR = [
{ title: { contains: search as string} },
{ resolution: { contains: search as string} },
{ category: { contains: search as string} },
]
}
const resolutions = await prisma.resolution.findMany({ where, orderBy: { createdAt: 'desc' } })
return res.json({ data: resolutions })
}
+35
View File
@@ -0,0 +1,35 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction, SHIPMENT_SEND_ROLES } from '@/lib/auth'
import { sendEmail, emailTemplate } from '@/lib/email'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res, SHIPMENT_SEND_ROLES)
if (!user) return
if (req.method !== 'POST') return res.status(405).end()
const { id } = req.query as { id: string }
const { clientEmail, subject, message } = req.body
if (!clientEmail || !subject || !message) {
return res.status(400).json({ error: 'clientEmail, subject, message required' })
}
const shipment = await prisma.shipment.findUnique({ where: { id }, include: { items: true } })
if (!shipment) return res.status(404).json({ error: 'Shipment not found' })
const itemsHtml = shipment.items.filter(i => i.included).map(i => `<li>${i.label}</li>`).join('')
await sendEmail(clientEmail, subject, emailTemplate('Quality Release Package', `
<p>${message.replace(/\n/g, '<br>')}</p>
<ul>${itemsHtml}</ul>
`))
const updated = await prisma.shipment.update({
where: { id },
data: { sentAt: new Date(), sentTo: clientEmail },
})
await logAction(user.id, 'UPDATE', 'Shipment', id, { sentAt: null }, { sentAt: updated.sentAt, sentTo: clientEmail })
return res.json({ data: updated })
}
+45
View File
@@ -0,0 +1,45 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction, generateRef } from '@/lib/auth'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
if (req.method === 'GET') {
const shipments = await prisma.shipment.findMany({
include: { items: { orderBy: { order: 'asc' } }, _count: { select: { escapes: true } } },
orderBy: { createdAt: 'desc' },
})
return res.json({ data: shipments })
}
if (req.method === 'POST') {
const { product, batch, client, clientEmail, shippedAt, items } = req.body
if (!product || !batch || !client || !shippedAt) {
return res.status(400).json({ error: 'product, batch, client, shippedAt required' })
}
const count = await prisma.shipment.count()
const ref = generateRef('REL', count)
const shipment = await prisma.shipment.create({
data: {
ref, product, batch, client, clientEmail,
shippedAt: new Date(shippedAt),
createdById: user.id,
items: {
create: (items || []).map((it: any, i: number) => ({
label: it.label, type: it.type || 'OTHER', included: it.included !== false, order: i,
}))
}
},
include: { items: { orderBy: { order: 'asc' } } },
})
await logAction(user.id, 'CREATE', 'Shipment', shipment.id, null, { ref, product, batch })
return res.status(201).json({ data: shipment })
}
res.status(405).end()
}
+40
View File
@@ -0,0 +1,40 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth } from '@/lib/auth'
// Auto-suggests release items tied to a product: recent first-build form
// submission counts for that product, plus recently resolved NCR fixes.
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
if (req.method !== 'GET') return res.status(405).end()
const { product } = req.query as { product?: string }
const items: { label: string; type: string; included: boolean }[] = []
if (product) {
const forms = await prisma.buildForm.findMany({
where: { product: { contains: product} },
include: { _count: { select: { submissions: true } } },
})
for (const f of forms) {
if (f._count.submissions > 0) {
items.push({ label: `${f.name}${f._count.submissions} submissions`, type: 'FORM_DATA', included: true })
}
}
}
// Recently resolved NCRs (last 90 days) as candidate fixes to include
const since = new Date()
since.setDate(since.getDate() - 90)
const recentNcrs = await prisma.nCR.findMany({
where: { status: 'RESOLVED', resolvedAt: { gte: since } },
orderBy: { resolvedAt: 'desc' },
take: 5,
})
for (const n of recentNcrs) {
items.push({ label: `${n.ref} fix — ${n.category || 'resolved'}`, type: 'NCR_FIX', included: false })
}
return res.json({ data: items })
}
+29
View File
@@ -0,0 +1,29 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction } from '@/lib/auth'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
if (req.method === 'GET') {
const items = await prisma.shippingStandardItem.findMany({ orderBy: { order: 'asc' } })
return res.json({ data: items })
}
if (req.method === 'POST') {
if (user.role !== 'ADMIN' && user.role !== 'QC') return res.status(403).json({ error: 'Admin/QC only' })
const { text, source } = req.body
if (!text) return res.status(400).json({ error: 'text required' })
const maxOrder = await prisma.shippingStandardItem.count()
const item = await prisma.shippingStandardItem.create({
data: { text, source: source || 'Baseline', order: maxOrder },
})
await logAction(user.id, 'CREATE', 'ShippingStandardItem', item.id, null, { text })
return res.status(201).json({ data: item })
}
res.status(405).end()
}
+39
View File
@@ -0,0 +1,39 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction } from '@/lib/auth'
import { notifyFormReviewReady } from '@/lib/email'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
if (req.method !== 'POST') return res.status(405).end()
const { formId, data } = req.body || {}
if (!formId || data === undefined) {
return res.status(400).json({ error: 'formId and data are required' })
}
const form = await prisma.buildForm.findUnique({ where: { id: formId } })
if (!form) return res.status(404).json({ error: 'Form not found' })
if (form.status !== 'ACTIVE') return res.status(400).json({ error: 'Form is not accepting submissions' })
await prisma.formSubmission.create({
data: { formId, submittedBy: user.id, data: JSON.stringify(data) },
})
const submissionCount = await prisma.formSubmission.count({ where: { formId } })
await logAction(user.id, 'CREATE', 'FormSubmission', formId, null, { submissionCount })
// First time the target is reached: flip to REVIEW_READY and notify admins.
if (submissionCount === form.minSubmissions) {
await prisma.buildForm.update({ where: { id: formId }, data: { status: 'REVIEW_READY' } })
const admins = await prisma.user.findMany({ where: { role: 'ADMIN', active: true } })
for (const admin of admins) {
await notifyFormReviewReady(form.name, submissionCount, admin.email, admin.id)
}
}
return res.status(201).json({ submissionCount })
}
+19
View File
@@ -0,0 +1,19 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth } from '@/lib/auth'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res, ['ADMIN', 'QC'])
if (!user) return
if (req.method === 'GET') {
const users = await prisma.user.findMany({
where: { active: true },
select: { id: true, name: true, email: true, role: true, department: true },
orderBy: { name: 'asc' },
})
return res.json({ data: users })
}
res.status(405).end()
}