Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
583 changes: 461 additions & 122 deletions scripts/workflow-test.mjs

Large diffs are not rendered by default.

113 changes: 113 additions & 0 deletions src/app/staff/interviews/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<WorkspaceShell
session={session}
eyebrow="Staff / Interviews"
title={interview.candidate?.candidate_name ?? "Interview Detail"}
metrics={[]}
>
<FactPanel title="Interview Details" facts={facts} />

<section className="detailPanel">
<h2>Actions</h2>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{interview.status !== 1 && (
<form action={updateInterviewStatusAction}>
<input type="hidden" name="interview_uuid" value={interview.request_interview_uuid} />
<input type="hidden" name="status" value={1} />
<Button type="submit" variant="default">Mark Completed</Button>
</form>
)}
{interview.status !== 2 && (
<form action={updateInterviewStatusAction}>
<input type="hidden" name="interview_uuid" value={interview.request_interview_uuid} />
<input type="hidden" name="status" value={2} />
<Button type="submit" variant="outline">Mark Cancelled</Button>
</form>
)}
{interview.status !== 0 && interview.status !== null && (
<form action={updateInterviewStatusAction}>
<input type="hidden" name="interview_uuid" value={interview.request_interview_uuid} />
<input type="hidden" name="status" value={0} />
<Button type="submit" variant="secondary">Reset to Scheduled</Button>
</form>
)}
</div>
</section>

<section className="detailPanel">
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{interview.candidate?.candidate_id && (
<Link href={`/staff/candidates?candidate=${interview.candidate.candidate_id}` as Route}>
<Button variant="outline">View Candidate</Button>
</Link>
)}
{interview.request?.request_uuid && (
<Link href={`/staff/requests/${interview.request.request_uuid}` as Route}>
<Button variant="outline">View Request</Button>
</Link>
)}
<Link href={"/staff/interviews" as Route}>
<Button variant="ghost">Back to Interviews</Button>
</Link>
</div>
</section>

{notice && (
<section className="detailPanel">
<p className="notice">
{notice === "interview-updated" && "Interview updated successfully."}
{notice === "not-found" && "Interview not found."}
{notice === "missing-fields" && "Missing required fields."}
</p>
</section>
)}
</WorkspaceShell>
);
}
30 changes: 30 additions & 0 deletions src/app/staff/interviews/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<WorkspaceShell session={session} eyebrow="Staff" title="Interviews" metrics={[]}>
<DataTable
title="Interview Pipeline"
description="Interviews scheduled and managed by you."
rows={rows}
rowHref={(row) => `/staff/interviews/${row.id}` as Route}
columns={[
{ key: "candidate", label: "Candidate", render: (row) => <strong>{row.candidate}</strong> },
{ 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) }
]}
/>
</WorkspaceShell>
);
}
102 changes: 61 additions & 41 deletions src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2236,6 +2247,8 @@ h2 {

.mobileTabBar {
display: none;
-webkit-tap-highlight-color: transparent;
user-select: none;
}

.featureGrid {
Expand Down Expand Up @@ -5948,47 +5961,53 @@ kbd {

.workspaceStage {
width: auto;
padding: 10px 10px 88px;
padding: 10px 10px 76px;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make content bottom padding safe-area aware to prevent clipped last items.

The tab bar accounts for safe-area-inset-bottom, but these content containers use fixed bottom padding. On iOS devices with larger insets, the final rows can sit under the bar.

Proposed fix
 `@media` (max-width: 768px) {
   .workspaceStage {
     width: auto;
-    padding: 10px 10px 76px;
+    padding: 10px 10px calc(76px + env(safe-area-inset-bottom, 0px));
   }
 }

 `@media` (max-width: 680px) {
   .hubMain {
     gap: 12px;
-    padding: 10px 10px 76px;
+    padding: 10px 10px calc(76px + env(safe-area-inset-bottom, 0px));
   }

   .journeyHome {
-    padding: 10px 10px 76px;
+    padding: 10px 10px calc(76px + env(safe-area-inset-bottom, 0px));
   }

   .studentOSMain {
-    padding: 10px 10px 76px;
+    padding: 10px 10px calc(76px + env(safe-area-inset-bottom, 0px));
   }
 }

 `@media` (max-width: 480px) {
   .workspaceStage {
-    padding: 8px 8px 72px;
+    padding: 8px 8px calc(72px + env(safe-area-inset-bottom, 0px));
   }
 }

Also applies to: 6022-6022, 6076-6076, 6117-6117, 9309-9309

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/styles.css` at line 5964, Replace the fixed bottom padding
declaration "padding: 10px 10px 76px;" with a safe-area aware bottom padding
that adds the device inset (use env(safe-area-inset-bottom) with a constant(...)
fallback) so the content bottom uses calculated value instead of a hard 76px;
update the same pattern at the other occurrences noted (the other "padding: ...
76px" instances) and ensure you keep the existing top/side paddings unchanged
while only changing the bottom padding expression.

}

.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);
}
}
Expand All @@ -6000,7 +6019,7 @@ kbd {

.hubMain {
gap: 12px;
padding: 10px 10px 88px;
padding: 10px 10px 76px;
}

.commandDesk {
Expand Down Expand Up @@ -6054,7 +6073,7 @@ kbd {
}

.journeyHome {
padding: 10px 10px 88px;
padding: 10px 10px 76px;
}

.roleBoundaryNotice {
Expand Down Expand Up @@ -6095,7 +6114,7 @@ kbd {
}

.studentOSMain {
padding: 10px 10px 88px;
padding: 10px 10px 76px;
}

.studentOSSidebar {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const protectedPaths = [
"/inspector"
];

const publicPaths = ["/login", "/"];
const publicPaths = ["/login", "/", "/games"];

export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
Expand Down
4 changes: 3 additions & 1 deletion src/modules/candidates/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand Down
33 changes: 32 additions & 1 deletion src/modules/requests/interview-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export async function updateInterviewAction(formData: FormData) {
const now = new Date();

const data: Record<string, unknown> = { 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;

Expand All @@ -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);
}
Comment on lines +139 to +141
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restrict status to supported values before persisting.

Number.isInteger(status) still allows unsupported states (e.g., 7), which can corrupt interview status semantics.

Suggested fix
-  if (!interviewUuid || !Number.isInteger(status)) {
+  const allowedStatuses = new Set([0, 1, 2]);
+  if (!interviewUuid || !allowedStatuses.has(status)) {
     redirect(`${basePath}?notice=missing-fields` as Route);
   }

Also applies to: 154-155

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/requests/interview-actions.ts` around lines 139 - 141, The
current guard uses Number.isInteger(status) which permits unsupported numeric
codes (e.g., 7); replace this with a whitelist check against the allowed
interview status values (e.g., an InterviewStatus enum or a supportedStatuses
array) so that you validate that status is one of those permitted values before
persisting; update both occurrences (the block checking interviewUuid/status
around the redirect and the similar check at the later location) to use this
inclusion check and keep the same redirect(`${basePath}?notice=missing-fields`
as Route) when validation fails.


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);
}
Comment on lines +143 to +149
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle missing interview for admin path to avoid unhandled update failure.

For non-staff users, no existence check is performed before update, so an invalid UUID can raise a runtime DB error instead of redirecting with not-found.

Suggested fix
-  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 existing = await prisma.request_interview.findFirst({
+    where:
+      session.role === "staff"
+        ? { request_interview_uuid: interviewUuid, staff_id: Number(session.id) }
+        : { request_interview_uuid: interviewUuid },
+    select: { request_interview_uuid: true }
+  });
+  if (!existing) redirect(`${basePath}?notice=not-found` as Route);

Also applies to: 152-155

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/requests/interview-actions.ts` around lines 143 - 149, Add an
existence check for the interview before calling prisma.request_interview.update
when session.role !== "staff": use prisma.request_interview.findFirst (or
findUnique) to verify the record for request_interview_uuid (same UUID used in
the update) and if not found perform redirect(`${basePath}?notice=not-found`)
like the staff branch does; apply the same check/redirect logic around the
update path referenced in the block that includes
prisma.request_interview.update (also update the duplicate logic noted at the
second occurrence around lines 152-155) so no update is attempted for an invalid
UUID.


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);
}
Loading
Loading