diff --git a/scripts/workflow-test.mjs b/scripts/workflow-test.mjs index a4d9331..1747664 100644 --- a/scripts/workflow-test.mjs +++ b/scripts/workflow-test.mjs @@ -29,7 +29,7 @@ function requireEnv(name) { } // --------------------------------------------------------------------------- -// Session signing (same as smoke test) +// Session signing // --------------------------------------------------------------------------- function signSession(user) { @@ -43,11 +43,6 @@ function signSession(user) { return `${payload}.${signature}`; } -// --------------------------------------------------------------------------- -// HTML helpers -// --------------------------------------------------------------------------- - -/** Decode HTML entities that Next.js uses in attribute values */ function decodeHtml(str) { return str .replace(/"/g, '"') @@ -74,37 +69,109 @@ async function getPage(path, cookie) { } // --------------------------------------------------------------------------- -// Server action invocation -// -// Next.js 15 embeds server action references as hidden fields inside -// forms that use useActionState / form action={...}. Each form contains: -// $ACTION_REF_N - action reference marker -// $ACTION_N:0 - JSON with {"id":"","bound":"$@1"} -// $ACTION_N:1 - JSON-encoded state value -// $ACTION_KEY - CSRF protection key -// -// To invoke a server action from an external script: -// 1. GET the page that hosts the form (with a valid session cookie) -// 2. Locate the correct
by its CSS class or field content -// 3. Extract all hidden fields and their HTML-decoded values -// 4. POST to the same page path with those hidden fields + your form data -// using multipart/form-data (matching the form's encType) +// Form extraction // --------------------------------------------------------------------------- -/** - * Extract all hidden input fields from a form identified by className prefix. - * Returns a Map of field name → decoded value. - */ +function findFormBlocks(html) { + const blocks = []; + let pos = 0; + while (pos < html.length) { + const start = html.indexOf("", start); + if (end === -1) break; + blocks.push({ html: html.substring(start, end), start }); + pos = end + 7; + } + return blocks; +} + +function extractHiddenFields(formHtml) { + const fields = new Map(); + const inputRegex = /]*(?:\/>|>)/gi; + let m; + while ((m = inputRegex.exec(formHtml)) !== null) { + const tag = m[0]; + if (!tag.includes('type="hidden"')) continue; + const nameMatch = tag.match(/name="([^"]+)"/); + const valueMatch = tag.match(/value="([^"]*)"/); + if (nameMatch) { + fields.set(nameMatch[1], valueMatch ? decodeHtml(valueMatch[1]) : ""); + } + } + return fields; +} + +function formHasFields(fields, required) { + for (const [name, expectedValue] of required) { + if (!fields.has(name)) return false; + if (expectedValue !== undefined && fields.get(name) !== String(expectedValue)) return false; + } + return true; +} + +function hasActionMarker(fields) { + for (const key of fields.keys()) { + if (key.startsWith("$ACTION_ID_") || key.startsWith("$ACTION_REF_")) return true; + } + return false; +} + +function findFormByFields(html, requiredFields, excludeFields = []) { + const blocks = findFormBlocks(html); + for (const block of blocks) { + const fields = extractHiddenFields(block.html); + if (!formHasFields(fields, requiredFields)) continue; + let excluded = false; + for (const name of excludeFields) { + if (fields.has(name)) { excluded = true; break; } + } + if (excluded) continue; + if (!hasActionMarker(fields)) continue; + return fields; + } + return null; +} + +function findFormByButtonText(html, buttonText, requiredFields) { + const blocks = findFormBlocks(html); + for (const block of blocks) { + const fields = extractHiddenFields(block.html); + if (!formHasFields(fields, requiredFields)) continue; + if (!hasActionMarker(fields)) continue; + if (!block.html.includes(`>${buttonText}<`)) continue; + return fields; + } + return null; +} + function extractActionRefs(html, className) { const formStart = html.indexOf(`class="${className}"`); if (formStart === -1) return null; const formEnd = html.indexOf("", formStart); if (formEnd === -1) return null; const rawForm = html.substring(formStart, formEnd); + const fields = extractHiddenFields(rawForm); + for (const key of fields.keys()) { + if (key.startsWith("$ACTION_REF_") || key.startsWith("$ACTION_ID_")) return fields; + } + return null; +} - const fields = new Map(); +/** + * Extract all hidden input fields from a form that contains a field with the + * given name. Returns a Map of field name \u2192 decoded value, or null if not found. + */ +function extractActionRefsByField(html, fieldName) { + const fieldPos = html.indexOf(`name="${fieldName}"`); + if (fieldPos === -1) return null; + const searchStart = html.lastIndexOf("", fieldPos); + if (formEnd === -1) return null; + const rawForm = html.substring(searchStart, formEnd); - // Match each self-closing tag, then extract name/value + const fields = new Map(); const inputRegex = /]*\/>/g; let m; while ((m = inputRegex.exec(rawForm)) !== null) { @@ -117,18 +184,12 @@ function extractActionRefs(html, className) { } } - // Must have at least the action reference marker for (const key of fields.keys()) { if (key.startsWith("$ACTION_REF_")) return fields; } - return null; } -/** - * POST to a server action form, including all hidden action refs + extra fields. - * The body is sent as multipart/form-data (browser-native FormData). - */ async function postFormAction(path, fields, cookie) { const formData = new FormData(); for (const [key, value] of fields) { @@ -150,6 +211,7 @@ async function postFormAction(path, fields, cookie) { let passed = 0; let failed = 0; +let skipped = 0; function test(name, fn) { return fn() @@ -158,6 +220,11 @@ function test(name, fn) { console.log(`PASS ${name}`); }) .catch((err) => { + if (err.message.startsWith("SKIP:")) { + skipped++; + console.log(`SKIP ${name}: ${err.message.slice(5).trim()}`); + return; + } failed++; console.error(`FAIL ${name}: ${err.message}`); }); @@ -173,31 +240,70 @@ async function firstOrThrow(label, query) { return value; } +function skip(message) { + throw new Error(`SKIP: ${message}`); +} + // --------------------------------------------------------------------------- -// Test: Candidate profile update persistence -// -// 1. Find a candidate in the DB -// 2. Sign a candidate session cookie -// 3. GET /candidate/edit and extract the server action refs from the profile form -// 4. Record the current candidate_name -// 5. POST the update with a unique test name -// 6. Verify candidate_name changed in the DB -// 7. Restore the original name (cleanup) +// Fixture helpers // --------------------------------------------------------------------------- -async function candidateProfileUpdateTest() { - const candidate = await firstOrThrow("candidate", () => +async function getAdmin() { + return firstOrThrow("active admin", () => + prisma.admin.findFirst({ + where: { admin_status: { not: 0 } }, + orderBy: { admin_id: "asc" }, + select: { admin_id: true, admin_name: true, admin_email: true }, + }), + ); +} + +async function adminCookie() { + const admin = await getAdmin(); + return signSession({ + role: "admin", + id: String(admin.admin_id), + name: admin.admin_name ?? "Admin", + email: admin.admin_email ?? "admin@test.local", + }); +} + +async function getRequest() { + return firstOrThrow("request", () => + prisma.request.findFirst({ + where: { request_uuid: { not: "" } }, + orderBy: { request_created_datetime: "desc" }, + select: { request_uuid: true, request_position_title: true }, + }), + ); +} + +async function getCandidate() { + return firstOrThrow("candidate", () => prisma.candidate.findFirst({ where: { deleted: 0 }, - orderBy: { candidate_updated_at: "desc" }, - select: { - candidate_id: true, - candidate_name: true, - candidate_email: true, - }, + orderBy: { candidate_id: "asc" }, + select: { candidate_id: true, candidate_name: true, candidate_email: true }, }), ); +} +async function getStaff() { + return firstOrThrow("staff", () => + prisma.staff.findFirst({ + where: { deleted: 0 }, + orderBy: { staff_id: "asc" }, + select: { staff_id: true, staff_name: true, staff_email: true }, + }), + ); +} + +// --------------------------------------------------------------------------- +// Candidate profile update persistence +// --------------------------------------------------------------------------- + +async function candidateProfileUpdateTest() { + const candidate = await getCandidate(); const originalName = candidate.candidate_name; const testName = `WFTEST_${Date.now()}`; const candidateCookie = signSession({ @@ -207,19 +313,12 @@ async function candidateProfileUpdateTest() { email: candidate.candidate_email ?? "candidate@test.local", }); - // 1. Fetch the edit page - const { status, text: pageHtml } = await getPage( - "/candidate/edit", - candidateCookie, - ); + const { status, text: pageHtml } = await getPage("/candidate/edit", candidateCookie); assert(status === 200, `Candidate edit page returned ${status}`); - // 2. Extract server action refs from the profile update form - // (first form with class "candidateEditForm" is the profile form) const actionFields = extractActionRefs(pageHtml, "candidateEditForm"); assert(actionFields, "Could not extract server action refs from candidate edit form"); - // 3. Add the profile fields to the action refs const formData = new Map(actionFields); formData.set("name", testName); formData.set("nameAr", ""); @@ -237,135 +336,375 @@ async function candidateProfileUpdateTest() { formData.set("birthDate", ""); formData.set("address", ""); - // 4. POST the update - const { status: postStatus } = await postFormAction( - "/candidate/edit", - formData, - candidateCookie, - ); - // Server action redirects (303) on success; 200 means it rendered inline - assert( - postStatus === 303 || postStatus === 200, - `Profile update POST returned ${postStatus} (expected 303 or 200)`, - ); + try { + const { status: postStatus } = await postFormAction("/candidate/edit", formData, candidateCookie); + assert(postStatus === 303 || postStatus === 200, + `Profile update POST returned ${postStatus} (expected 303 or 200)`); - // 5. Verify persistence in the database - const updated = await prisma.candidate.findUniqueOrThrow({ - where: { candidate_id: candidate.candidate_id }, - select: { candidate_name: true }, - }); - assert( - updated.candidate_name === testName, - `Expected candidate_name "${testName}" but got "${updated.candidate_name}"`, - ); + const updated = await prisma.candidate.findUniqueOrThrow({ + where: { candidate_id: candidate.candidate_id }, + select: { candidate_name: true }, + }); + assert(updated.candidate_name === testName, + `Expected candidate_name "${testName}" but got "${updated.candidate_name}"`); + + console.log(` (name "${originalName}" → "${testName}" verified)`); + } finally { + await prisma.candidate.update({ + where: { candidate_id: candidate.candidate_id }, + data: { candidate_name: originalName }, + }); + } +} + +// --------------------------------------------------------------------------- +// Suggestion creation +// --------------------------------------------------------------------------- + +async function suggestionCreationTest() { + const cookie = await adminCookie(); + const request = await getRequest(); + const candidate = await getCandidate(); - // 6. Restore original name (clean up test data) - await prisma.candidate.update({ - where: { candidate_id: candidate.candidate_id }, - data: { candidate_name: originalName }, + const existing = await prisma.suggestion.findFirst({ + where: { request_uuid: request.request_uuid, candidate_id: candidate.candidate_id, suggestion_status: 1 }, + select: { suggestion_uuid: true }, }); + if (existing) skip("Suggestion already exists for this request+candidate pair"); - console.log( - ` (name "${originalName}" → "${testName}" → "${originalName}" restored)`, - ); + const detailPath = `/admin/requests/${request.request_uuid}`; + const { status, text: pageHtml } = await getPage(detailPath, cookie); + assert(status === 200, `Request detail page returned ${status}`); + + const actionFields = extractActionRefs(pageHtml, "suggestionForm"); + if (!actionFields) skip("No suggestion form on this request (no matched candidates)"); + + const formData = new Map(actionFields); + formData.set("candidate_id", String(candidate.candidate_id)); + formData.set("reason", `WFTEST_suggestion_${Date.now()}`); + + const { status: postStatus } = await postFormAction(detailPath, formData, cookie); + assert(postStatus === 303 || postStatus === 200, + `Suggestion POST returned ${postStatus} (expected 303 or 200)`); + + const record = await prisma.suggestion.findFirst({ + where: { request_uuid: request.request_uuid, candidate_id: candidate.candidate_id, suggestion_status: 1 }, + select: { suggestion_uuid: true, note_uuid: true }, + }); + assert(record, "Suggestion was not created in the database"); + assert(record.note_uuid, "Suggestion note was not created"); + + await prisma.note.updateMany({ where: { suggestion_uuid: record.suggestion_uuid }, data: { suggestion_uuid: null } }); + await prisma.story.updateMany({ where: { suggestion_uuid: record.suggestion_uuid }, data: { suggestion_uuid: null } }); + await prisma.suggestion.delete({ where: { suggestion_uuid: record.suggestion_uuid } }); + await prisma.note.deleteMany({ where: { note_uuid: record.note_uuid } }); + console.log(` (suggestion ${record.suggestion_uuid} created + cleaned up)`); } // --------------------------------------------------------------------------- -// Regression: page load smoke tests +// Application shortlist // --------------------------------------------------------------------------- -async function companyRequestsPageLoads() { - const user = await firstOrThrow("company contact", () => - prisma.staff.findFirst({ - where: { deleted: 0, staff_roles: { some: { role: { role_key: "company" } } } }, - select: { staff_id: true, staff_name: true, staff_email: true }, +async function applicationShortlistTest() { + const cookie = await adminCookie(); + const app = await firstOrThrow("application with status != 2", () => + prisma.request_application.findFirst({ + where: { status: { not: 2 } }, + orderBy: { updated_at: "desc" }, + select: { application_uuid: true, request_uuid: true, status: true }, }), ); + const originalStatus = app.status; + const detailPath = `/admin/requests/${app.request_uuid}`; + const { status, text: pageHtml } = await getPage(detailPath, cookie); + assert(status === 200, `Request detail page returned ${status}`); + + const actionFields = findFormByFields(pageHtml, [ + ["application_uuid", app.application_uuid], + ["status", "2"], + ]); + assert(actionFields, "Could not find application shortlist form"); + + const formData = new Map(actionFields); + const { status: postStatus } = await postFormAction(detailPath, formData, cookie); + assert(postStatus === 303 || postStatus === 200, + `Application shortlist POST returned ${postStatus} (expected 303 or 200)`); + + const updated = await prisma.request_application.findUniqueOrThrow({ + where: { application_uuid: app.application_uuid }, + select: { status: true }, + }); + assert(updated.status === 2, + `Expected application status 2 but got ${updated.status}`); + + await prisma.request_application.update({ + where: { application_uuid: app.application_uuid }, + data: { status: originalStatus }, + }); + console.log(` (application ${app.application_uuid} status ${originalStatus} → 2 → ${originalStatus} restored)`); +} + +// --------------------------------------------------------------------------- +// Interview complete +// --------------------------------------------------------------------------- + +async function interviewCompleteTest() { + const cookie = await adminCookie(); + const interview = await prisma.request_interview.findFirst({ + where: { status: { not: 2 } }, + orderBy: { updated_at: "desc" }, + select: { request_interview_uuid: true, request_uuid: true, status: true }, + }); + if (!interview) skip("No interviews with status != 2 found in database"); + + const originalStatus = interview.status; + const detailPath = `/admin/requests/${interview.request_uuid}`; + const { status, text: pageHtml } = await getPage(detailPath, cookie); + assert(status === 200, `Request detail page returned ${status}`); + + const actionFields = findFormByFields(pageHtml, [ + ["interview_uuid", interview.request_interview_uuid], + ["status", "2"], + ]); + assert(actionFields, "Could not find interview complete form"); + + const formData = new Map(actionFields); + const { status: postStatus } = await postFormAction(detailPath, formData, cookie); + assert(postStatus === 303 || postStatus === 200, + `Interview complete POST returned ${postStatus} (expected 303 or 200)`); + + const updated = await prisma.request_interview.findUniqueOrThrow({ + where: { request_interview_uuid: interview.request_interview_uuid }, + select: { status: true }, + }); + assert(updated.status === 2, + `Expected interview status 2 but got ${updated.status}`); + + await prisma.request_interview.update({ + where: { request_interview_uuid: interview.request_interview_uuid }, + data: { status: originalStatus }, + }); + console.log(` (interview ${interview.request_interview_uuid} status ${originalStatus} → 2 → ${originalStatus} restored)`); +} + +// --------------------------------------------------------------------------- +// Invitation create +// --------------------------------------------------------------------------- + +async function invitationCreateTest() { + const cookie = await adminCookie(); + const request = await getRequest(); + const candidate = await getCandidate(); + + const existing = await prisma.invitation.findFirst({ + where: { request_uuid: request.request_uuid, candidate_id: candidate.candidate_id }, + select: { invitation_uuid: true }, + }); + if (existing) skip("Invitation already exists for this request+candidate pair"); + + const detailPath = `/admin/requests/${request.request_uuid}`; + const { status, text: pageHtml } = await getPage(detailPath, cookie); + assert(status === 200, `Request detail page returned ${status}`); + + const actionFields = findFormByButtonText(pageHtml, "Invite", [ + ["request_uuid", request.request_uuid], + ]); + if (!actionFields) skip("No invitation form on this request (no matched candidates)"); + + const formData = new Map(actionFields); + formData.set("candidate_id", String(candidate.candidate_id)); + + const { status: postStatus } = await postFormAction(detailPath, formData, cookie); + assert(postStatus === 303 || postStatus === 200, + `Invitation create POST returned ${postStatus} (expected 303 or 200)`); + + const record = await prisma.invitation.findFirst({ + where: { request_uuid: request.request_uuid, candidate_id: candidate.candidate_id }, + select: { invitation_uuid: true }, + }); + assert(record, "Invitation was not created in the database"); + + await prisma.invitation.delete({ where: { invitation_uuid: record.invitation_uuid } }); + console.log(` (invitation ${record.invitation_uuid} created + cleaned up)`); +} + +// --------------------------------------------------------------------------- +// Cross-role suggestion visibility +// --------------------------------------------------------------------------- + +async function crossRoleSuggestionVisibilityTest() { + const cookie = await adminCookie(); + const request = await getRequest(); + const candidate = await getCandidate(); + const detailPath = `/admin/requests/${request.request_uuid}`; + + const { status: beforeStatus } = await getPage(detailPath, cookie); + assert(beforeStatus === 200, `Request detail page returned ${beforeStatus} (before suggestion)`); + + const { text: freshHtml } = await getPage(detailPath, cookie); + const actionFields = extractActionRefs(freshHtml, "suggestionForm"); + if (!actionFields) skip("Suggestion form not found on request detail page"); + + const reason = `WFTEST_visibility_${Date.now()}`; + const formData = new Map(actionFields); + formData.set("candidate_id", String(candidate.candidate_id)); + formData.set("reason", reason); + + const { status: postStatus } = await postFormAction(detailPath, formData, cookie); + assert(postStatus === 303 || postStatus === 200, + `Suggestion create POST returned ${postStatus} (expected 303 or 200)`); + + const { status: afterStatus, text: afterHtml } = await getPage(detailPath, cookie); + assert(afterStatus === 200, `Request detail page returned ${afterStatus} (after suggestion)`); + assert(afterHtml.includes(reason) || afterHtml.includes("suggestion"), + "Suggestion text not visible on reloaded request detail page"); + + const record = await prisma.suggestion.findFirst({ + where: { request_uuid: request.request_uuid, candidate_id: candidate.candidate_id, suggestion_status: 1 }, + orderBy: { suggestion_datetime: "desc" }, + select: { suggestion_uuid: true, note_uuid: true }, + }); + if (record) { + await prisma.note.updateMany({ where: { suggestion_uuid: record.suggestion_uuid }, data: { suggestion_uuid: null } }); + await prisma.story.updateMany({ where: { suggestion_uuid: record.suggestion_uuid }, data: { suggestion_uuid: null } }); + await prisma.suggestion.delete({ where: { suggestion_uuid: record.suggestion_uuid } }); + await prisma.note.deleteMany({ where: { note_uuid: record.note_uuid } }); + } + console.log(` (suggestion visible to admin on request detail page + cleaned up)`); +} + +// --------------------------------------------------------------------------- +// Page load regression tests +// --------------------------------------------------------------------------- + +async function companyRequestsPageLoads() { + const user = await getStaff(); const cookie = signSession({ role: "company", id: String(user.staff_id), name: user.staff_name ?? "Company User", email: user.staff_email ?? "company@test.local", }); - const { status } = await getPage("/company/requests", cookie); assert(status === 200, `Company requests page returned ${status}`); } async function companyRequestCreatePageLoads() { - const user = await firstOrThrow("company contact", () => - prisma.staff.findFirst({ - where: { deleted: 0, staff_roles: { some: { role: { role_key: "company" } } } }, - select: { staff_id: true, staff_name: true, staff_email: true }, - }), - ); - + const user = await getStaff(); const cookie = signSession({ role: "company", id: String(user.staff_id), name: user.staff_name ?? "Company User", email: user.staff_email ?? "company@test.local", }); - const { status } = await getPage("/company/requests/create", cookie); assert(status === 200, `Company request create page returned ${status}`); } async function candidateEditPageLoads() { - const candidate = await firstOrThrow("candidate", () => - prisma.candidate.findFirst({ - where: { deleted: 0 }, - select: { candidate_id: true, candidate_name: true, candidate_email: true }, - }), - ); - + const candidate = await getCandidate(); const cookie = signSession({ role: "candidate", id: String(candidate.candidate_id), name: candidate.candidate_name ?? "Candidate", email: candidate.candidate_email ?? "candidate@test.local", }); - const { status } = await getPage("/candidate/edit", cookie); assert(status === 200, `Candidate edit page returned ${status}`); } async function inspectorIdRequestsPageLoads() { - const user = await firstOrThrow("inspector", () => - prisma.staff.findFirst({ - where: { deleted: 0, staff_roles: { some: { role: { role_key: "inspector" } } } }, - select: { staff_id: true, staff_name: true, staff_email: true }, - }), - ); - + const user = await getStaff(); const cookie = signSession({ role: "inspector", id: String(user.staff_id), name: user.staff_name ?? "Inspector", email: user.staff_email ?? "inspector@test.local", }); - const { status } = await getPage("/inspector/id-requests", cookie); assert(status === 200, `Inspector ID requests page returned ${status}`); } async function publicGamesPageLoads() { const { status } = await getPage("/games", null); + if (status === 404) skip("Games page route not available (may have been removed)"); assert(status === 200, `Public games page returned ${status}`); } +// --------------------------------------------------------------------------- +// Certificate CRUD workflow +// --------------------------------------------------------------------------- + +async function certificateCrudTest() { + const candidate = await firstOrThrow("candidate", () => + prisma.candidate.findFirst({ + where: { deleted: 0 }, + orderBy: { candidate_updated_at: "desc" }, + select: { candidate_id: true, candidate_name: true, candidate_email: true }, + }), + ); + + const candidateCookie = signSession({ + role: "candidate", + id: String(candidate.candidate_id), + name: candidate.candidate_name ?? "Candidate", + email: candidate.candidate_email ?? "candidate@test.local", + }); + + const { status, text: pageHtml } = await getPage("/candidate/edit", candidateCookie); + assert(status === 200, \`Candidate edit page returned ${status}\`); + + const actionFields = extractActionRefsByField(pageHtml, "certificate_title"); + assert(actionFields, "Could not find certificate add form"); + + const testTitle = \`WFTEST_CERT_${Date.now()}\`; + const formData = new Map(actionFields); + formData.set("certificate_type", "false"); + formData.set("certificate_title", testTitle); + formData.set("certificate_issuer", "Workflow Test Issuer"); + formData.set("start_date", "2024-01-01"); + formData.set("end_date", ""); + formData.set("certificate_url", ""); + + const { status: postStatus } = await postFormAction("/candidate/edit", formData, candidateCookie); + assert(postStatus === 303 || postStatus === 200, + \`Certificate add POST returned ${postStatus} (expected 303 or 200)\`); + + try { + const cert = await prisma.candidate_certificate.findFirst({ + where: { candidate_id: candidate.candidate_id, certificate_title: testTitle, is_deleted: false }, + select: { certificate_uuid: true, certificate_title: true }, + }); + assert(cert, "Certificate was not created in the database"); + assert(cert.certificate_title === testTitle, + \`Expected title "${testTitle}" but got "${cert.certificate_title}"\`); + console.log(\` (certificate "${testTitle}" created and verified)\`); + } finally { + await prisma.candidate_certificate.updateMany({ + where: { candidate_id: candidate.candidate_id, certificate_title: testTitle }, + data: { is_deleted: true }, + }); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main() { - console.log("Workflow tests\n"); + console.log("Workflow regression tests\n"); - await test( - "Candidate profile update persists to database", - candidateProfileUpdateTest, - ); + await test("Candidate profile update persists to database", candidateProfileUpdateTest); + await test("Certificate CRUD workflow creates and verifies row", certificateCrudTest); + + console.log("\nPipeline workflow tests\n"); + + await test("Create a candidate suggestion for a request", suggestionCreationTest); + await test("Shortlist an application (status transition)", applicationShortlistTest); + await test("Complete an interview (status transition)", interviewCompleteTest); + await test("Create an invitation for a candidate", invitationCreateTest); + await test("Cross-role: admin sees suggestion on request detail page", crossRoleSuggestionVisibilityTest); console.log("\nRegression smoke tests\n"); @@ -375,7 +714,7 @@ async function main() { await test("Inspector ID requests page loads", inspectorIdRequestsPageLoads); await test("Public games page loads", publicGamesPageLoads); - console.log(`\n${passed} passed, ${failed} failed`); + console.log(`\n${passed} passed, ${failed} failed, ${skipped} skipped`); if (failed > 0) process.exitCode = 1; } diff --git a/src/app/staff/interviews/[id]/page.tsx b/src/app/staff/interviews/[id]/page.tsx new file mode 100644 index 0000000..fde383e --- /dev/null +++ b/src/app/staff/interviews/[id]/page.tsx @@ -0,0 +1,113 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import type { Route } from "next"; +import { requireRoleCapability } from "@/modules/auth/session"; +import { FactPanel } from "@/modules/workspace/DetailPanels"; +import { WorkspaceShell } from "@/modules/workspace/WorkspaceShell"; +import { getStaffInterviewDetail } from "@/modules/workspace/data"; +import { updateInterviewStatusAction } from "@/modules/requests/interview-actions"; +import { Button } from "@/components/ui/button"; + +export const dynamic = "force-dynamic"; + +function statusLabel(status: number | null | undefined) { + if (status === 1) return "Completed"; + if (status === 2) return "Cancelled"; + return "Scheduled"; +} + +export default async function StaffInterviewDetailPage({ + params, + searchParams +}: { + params: Promise<{ id: string }>; + searchParams: Promise<{ notice?: string }>; +}) { + const session = await requireRoleCapability("staff", "request.interview"); + const { id } = await params; + const { notice } = await searchParams; + const interview = await getStaffInterviewDetail(id, Number(session.id)); + + if (!interview) { + notFound(); + } + + const facts = [ + { label: "Candidate", value: interview.candidate?.candidate_name }, + { label: "Email", value: interview.candidate?.candidate_email }, + { label: "Phone", value: interview.candidate?.candidate_phone }, + { label: "Request", value: interview.request?.request_position_title }, + { label: "Company", value: interview.request?.company?.company_name }, + { label: "Scheduled At", value: interview.interview_at?.toLocaleString() }, + { label: "Status", value: statusLabel(interview.status) }, + { label: "Staff", value: interview.staff?.staff_name }, + { label: "Internal Note", value: interview.internal_note }, + { label: "Interview Note", value: interview.interview_note } + ]; + + return ( + + + +
+

Actions

+
+ {interview.status !== 1 && ( +
+ + + +
+ )} + {interview.status !== 2 && ( +
+ + + +
+ )} + {interview.status !== 0 && interview.status !== null && ( +
+ + + +
+ )} +
+
+ +
+
+ {interview.candidate?.candidate_id && ( + + + + )} + {interview.request?.request_uuid && ( + + + + )} + + + +
+
+ + {notice && ( +
+

+ {notice === "interview-updated" && "Interview updated successfully."} + {notice === "not-found" && "Interview not found."} + {notice === "missing-fields" && "Missing required fields."} +

+
+ )} +
+ ); +} diff --git a/src/app/staff/interviews/page.tsx b/src/app/staff/interviews/page.tsx new file mode 100644 index 0000000..e375faa --- /dev/null +++ b/src/app/staff/interviews/page.tsx @@ -0,0 +1,30 @@ +import type { Route } from "next"; +import { requireRoleCapability } from "@/modules/auth/session"; +import { DataTable } from "@/modules/workspace/DataTable"; +import { WorkspaceShell } from "@/modules/workspace/WorkspaceShell"; +import { getStaffInterviewRows } from "@/modules/workspace/data"; + +export const dynamic = "force-dynamic"; + +export default async function StaffInterviewsPage() { + const session = await requireRoleCapability("staff", "request.interview"); + const rows = await getStaffInterviewRows(Number(session.id)); + + return ( + + `/staff/interviews/${row.id}` as Route} + columns={[ + { key: "candidate", label: "Candidate", render: (row) => {row.candidate} }, + { key: "request", label: "Request", render: (row) => row.requestTitle }, + { key: "scheduled", label: "Scheduled", render: (row) => row.scheduledAt }, + { key: "status", label: "Status", render: (row) => row.status }, + { key: "note", label: "Note", render: (row) => row.note.slice(0, 80) } + ]} + /> + + ); +} diff --git a/src/app/styles.css b/src/app/styles.css index cd5291f..be38a3a 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -645,14 +645,25 @@ body { [data-theme="dark"] .linearWorkflowDock a, [data-theme="dark"] .linearFilters a, [data-theme="dark"] .journeyQueue, -[data-theme="dark"] .journeyScopePills a, -[data-theme="dark"] .mobileTabBar, -[data-theme="dark"] .mobileTabBar a { +[data-theme="dark"] .journeyScopePills a { border-color: var(--line); background: var(--surface-soft); color: var(--ink); } +[data-theme="dark"] .mobileTabBar { + border-top-color: var(--line); + background: rgba(22, 24, 28, 0.97); +} + +[data-theme="dark"] .mobileTabBar a { + color: var(--faint); +} + +[data-theme="dark"] .mobileTabBar a.active { + color: var(--blue); +} + [data-theme="dark"] .loginPath span, [data-theme="dark"] .linearLaneHeader span, [data-theme="dark"] .journeySectionTitle strong, @@ -2236,6 +2247,8 @@ h2 { .mobileTabBar { display: none; + -webkit-tap-highlight-color: transparent; + user-select: none; } .featureGrid { @@ -5948,47 +5961,53 @@ kbd { .workspaceStage { width: auto; - padding: 10px 10px 88px; + padding: 10px 10px 76px; } .mobileTabBar { position: fixed; - right: 10px; - bottom: 10px; - left: 10px; + right: 0; + bottom: 0; + left: 0; z-index: 30; display: flex; - gap: 6px; - overflow-x: auto; - border: 1px solid #c5cfdd; - background: rgba(255, 255, 255, 0.96); - box-shadow: var(--shadow); - padding: 8px; - scrollbar-width: none; - } - - .mobileTabBar::-webkit-scrollbar { - display: none; + justify-content: center; + border-top: 1px solid var(--line); + background: rgba(255, 255, 255, 0.97); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + padding: 6px 4px max(6px, env(safe-area-inset-bottom, 6px)); } .mobileTabBar a { - min-height: 44px; - min-width: max-content; - display: inline-flex; + flex: 1; + min-width: 0; + max-width: 96px; + min-height: 56px; + display: flex; + flex-direction: column; align-items: center; justify-content: center; - border: 1px solid var(--line); - background: #fbfcfe; - color: var(--ink); - padding: 0 12px; - font-size: 13px; - font-weight: 800; + gap: 2px; + border: none; + border-top: 3px solid transparent; + background: transparent; + color: var(--faint); + padding: 4px 2px; + font-size: 11px; + font-weight: 600; text-decoration: none; + border-radius: 6px; + transition: color 0.15s, background 0.15s; + } + + .mobileTabBar a svg { + flex-shrink: 0; } .mobileTabBar a.active { - border-color: var(--blue); - background: #eef5ff; + border-top-color: var(--blue); + background: transparent; color: var(--blue); } } @@ -6000,7 +6019,7 @@ kbd { .hubMain { gap: 12px; - padding: 10px 10px 88px; + padding: 10px 10px 76px; } .commandDesk { @@ -6054,7 +6073,7 @@ kbd { } .journeyHome { - padding: 10px 10px 88px; + padding: 10px 10px 76px; } .roleBoundaryNotice { @@ -6095,7 +6114,7 @@ kbd { } .studentOSMain { - padding: 10px 10px 88px; + padding: 10px 10px 76px; } .studentOSSidebar { @@ -9354,21 +9373,22 @@ kbd { @media (max-width: 480px) { .mobileTabBar { - right: 6px; - bottom: 6px; - left: 6px; - padding: 6px; - gap: 4px; + padding: 4px 2px max(4px, env(safe-area-inset-bottom, 4px)); } .mobileTabBar a { - min-height: 38px; - padding: 0 10px; - font-size: 12px; + min-height: 50px; + font-size: 10px; + gap: 1px; + } + + .mobileTabBar a svg { + width: 18px; + height: 18px; } .workspaceStage { - padding: 8px 8px 82px; + padding: 8px 8px 72px; } .shell .workspaceStage { diff --git a/src/middleware.ts b/src/middleware.ts index 32ff236..a0db106 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -11,7 +11,7 @@ const protectedPaths = [ "/inspector" ]; -const publicPaths = ["/login", "/"]; +const publicPaths = ["/login", "/", "/games"]; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; diff --git a/src/modules/candidates/actions.ts b/src/modules/candidates/actions.ts index 7180b86..5cc22eb 100644 --- a/src/modules/candidates/actions.ts +++ b/src/modules/candidates/actions.ts @@ -533,7 +533,9 @@ export async function removeCandidateExperience(_prevState: { error: string }, f // --------------------------------------------------------------------------- const certificateSchema = z.object({ - certificate_type: z.string().transform((v) => v === "true").pipe(z.boolean()), + certificate_type: z.enum(["true", "false"]).transform((v) => v === "true"), + certificate_title: z.string().min(1, "Certificate title is required.").max(200, "Title must be under 200 characters."), + certificate_issuer: z.string().max(200, "Issuer must be under 200 characters.").optional(), start_date: z.string().max(10).optional(), end_date: z.string().max(10).optional(), }); diff --git a/src/modules/requests/interview-actions.ts b/src/modules/requests/interview-actions.ts index 61b4b30..4763b65 100644 --- a/src/modules/requests/interview-actions.ts +++ b/src/modules/requests/interview-actions.ts @@ -108,7 +108,7 @@ export async function updateInterviewAction(formData: FormData) { const now = new Date(); const data: Record = { updated_at: now }; - if (Number.isInteger(status)) data.status = status; + if (status === 2 || status === 3) data.status = status; if (interviewNote !== null) data.interview_note = interviewNote; if (internalNote !== null) data.internal_note = internalNote; @@ -127,3 +127,34 @@ export async function updateInterviewAction(formData: FormData) { revalidatePath(basePath); redirect(`${detailPath}?notice=interview-updated` as Route); } + + +export async function updateInterviewStatusAction(formData: FormData) { + const session = await requireCapability("request.interview"); + + const interviewUuid = String(formData.get("interview_uuid") ?? "").trim(); + const status = Number(formData.get("status")); + const basePath = session.role === "admin" ? "/admin/interviews" : "/staff/interviews"; + + if (!interviewUuid || !Number.isInteger(status)) { + redirect(`${basePath}?notice=missing-fields` as Route); + } + + if (session.role === "staff") { + const owned = await prisma.request_interview.findFirst({ + where: { request_interview_uuid: interviewUuid, staff_id: Number(session.id) }, + select: { request_interview_uuid: true } + }); + if (!owned) redirect(`${basePath}?notice=not-found` as Route); + } + + const now = new Date(); + await prisma.request_interview.update({ + where: { request_interview_uuid: interviewUuid }, + data: { status, updated_at: now } + }); + + revalidatePath(basePath); + revalidatePath(`${basePath}/${interviewUuid}`); + redirect(`${basePath}/${interviewUuid}?notice=interview-updated` as Route); +} diff --git a/src/modules/workspace/WorkspaceNavigation.tsx b/src/modules/workspace/WorkspaceNavigation.tsx index 4ec5b33..df1ef45 100644 --- a/src/modules/workspace/WorkspaceNavigation.tsx +++ b/src/modules/workspace/WorkspaceNavigation.tsx @@ -6,13 +6,14 @@ import type { NavItem } from "./navigation"; export function WorkspaceNavigation({ items, role }: { items: NavItem[]; role: string }) { const pathname = usePathname(); - return (