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
12 changes: 12 additions & 0 deletions prisma/migrations/20260527230000_add_external_id/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- Add externalId to Invite + Employee for cross-system linking
-- (partner system's stable ID, e.g. NyTex staff-portal's "NTX-2053").

-- Invite: non-unique — admins may re-mint after expiry, both invites
-- carry the same partner ID and converge on the same Employee.
ALTER TABLE "Invite" ADD COLUMN "externalId" TEXT;
CREATE INDEX "Invite_externalId_idx" ON "Invite"("externalId");

-- Employee: sparse-unique — one Employee per externalId per install;
-- nulls allowed so open-i9 standalone (no partner system) still works.
ALTER TABLE "Employee" ADD COLUMN "externalId" TEXT;
CREATE UNIQUE INDEX "Employee_externalId_key" ON "Employee"("externalId");
13 changes: 13 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ model Invite {
token String @unique @default(cuid())
emailHint String? // optional: who this invite is for (display only)
nameHint String? // optional: employee name hint
// Stable identifier from a partner system (e.g. NyTex staff-portal's
// staffNumber "NTX-2053"). When set, the submission flow links to the
// existing Employee with the same externalId instead of creating a new
// one. Non-unique because admins may re-mint a fresh invite after one
// expires; both invites point at the same partner-system person.
externalId String?
expiresAt DateTime
usedAt DateTime?
employeeId String? // linked after submission
Expand All @@ -81,6 +87,7 @@ model Invite {
createdAt DateTime @default(now())

@@index([token])
@@index([externalId])
}

model Employee {
Expand All @@ -94,6 +101,12 @@ model Employee {
hireDate DateTime? // "Engagement date" for contractors
terminatedAt DateTime? // "End of engagement" for contractors
notes String?
// Stable identifier from a partner system. Populated when an Invite
// with externalId is used. Sparse-unique: nulls allowed (open-i9
// standalone has no partner system), but once stamped, one Employee
// per externalId per install. This is the canonical cross-system
// anchor — partner sync code matches on it.
externalId String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Expand Down
23 changes: 21 additions & 2 deletions src/app/admin/employees/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ interface EmployeeRow {
hireDate: string | null;
terminatedAt: string | null;
notes: string | null;
// Stable identifier from the partner system (e.g. NyTex staff-portal's
// "NTX-2053"). Null for employees not tied to a partner.
externalId: string | null;
createdAt: string;
submissionCount: number;
latestSubmissionDate: string | null;
Expand Down Expand Up @@ -241,6 +244,9 @@ export default function AdminEmployeesPage() {
(safeCurrentPage - 1) * PAGE_SIZE,
safeCurrentPage * PAGE_SIZE
);
// Hide the External ID column entirely on standalone open-i9 installs
// where no row carries a partner-system anchor — keeps the table tidy.
const hasExternalIds = paged.some((e) => !!e.externalId);

async function handleSendRenewal(employeeId: string) {
setRenewalLoading(employeeId);
Expand Down Expand Up @@ -343,6 +349,14 @@ export default function AdminEmployeesPage() {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
{/* External ID column shows only when at least one row
on the page has one — keeps the table clean for any
standalone open-i9 install with no partner system. */}
{hasExternalIds ? (
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
External ID
</th>
) : null}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
Expand All @@ -366,7 +380,7 @@ export default function AdminEmployeesPage() {
<tbody className="bg-white divide-y divide-gray-200">
{loading ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center">
<td colSpan={hasExternalIds ? 8 : 7} className="px-6 py-12 text-center">
<div className="flex justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600" />
</div>
Expand All @@ -375,7 +389,7 @@ export default function AdminEmployeesPage() {
) : paged.length === 0 ? (
<tr>
<td
colSpan={7}
colSpan={hasExternalIds ? 8 : 7}
className="px-6 py-12 text-center text-sm text-gray-500"
>
No employees found
Expand All @@ -391,6 +405,11 @@ export default function AdminEmployeesPage() {
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{emp.firstName} {emp.lastName}
</td>
{hasExternalIds ? (
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-700">
{emp.externalId ?? "—"}
</td>
) : null}
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{emp.email}
</td>
Expand Down
19 changes: 19 additions & 0 deletions src/app/admin/invites/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ interface Invite {
token: string;
emailHint: string | null;
nameHint: string | null;
// Partner-system stable ID (e.g. NyTex's "NTX-2053"). Stamped at
// create time; propagates to the linked Employee at submission time.
externalId: string | null;
expiresAt: string;
usedAt: string | null;
isRenewal: boolean;
Expand Down Expand Up @@ -421,6 +424,14 @@ export default function AdminInvitesPage() {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name Hint
</th>
{/* External ID column shows only when at least one row
has one — standalone installs keep the original
layout. */}
{invites.some((i) => !!i.externalId) ? (
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
External ID
</th>
) : null}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email Hint
</th>
Expand All @@ -444,6 +455,7 @@ export default function AdminInvitesPage() {
<tbody className="bg-white divide-y divide-gray-200">
{invites.map((invite) => {
const status = getInviteStatus(invite);
const showExternalIdCol = invites.some((i) => !!i.externalId);
return (
<tr key={invite.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
Expand All @@ -456,6 +468,13 @@ export default function AdminInvitesPage() {
</span>
)}
</td>
{showExternalIdCol ? (
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-700">
{invite.externalId ?? (
<span className="text-gray-400">--</span>
)}
</td>
) : null}
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{invite.emailHint || (
<span className="text-gray-400">--</span>
Expand Down
1 change: 1 addition & 0 deletions src/app/api/employees/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export async function GET(request: Request) {
hireDate: emp.hireDate,
terminatedAt: emp.terminatedAt,
notes: emp.notes,
externalId: emp.externalId,
createdAt: emp.createdAt,
updatedAt: emp.updatedAt,
submissionCount,
Expand Down
8 changes: 8 additions & 0 deletions src/app/api/invites/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ const createInviteSchema = z.object({
isRenewal: z.boolean().optional(),
employeeId: z.string().optional(),
workerType: z.enum(["employee", "contractor"]).optional().default("employee"),
// Stable partner-system identifier (e.g. NyTex staff-portal's
// staffNumber "NTX-2053"). Propagates to Invite.externalId, then to
// Employee.externalId at submission time. Partner sync code matches
// on it deterministically instead of falling back to fragile
// first+last name lookups.
externalId: z.string().optional(),
});

export async function GET(request: Request) {
Expand All @@ -35,6 +41,7 @@ export async function GET(request: Request) {
token: true,
emailHint: true,
nameHint: true,
externalId: true,
expiresAt: true,
usedAt: true,
isRenewal: true,
Expand Down Expand Up @@ -110,6 +117,7 @@ export async function POST(request: Request) {
data: {
emailHint: data.emailHint || null,
nameHint: data.nameHint || null,
externalId: data.externalId || null,
expiresAt,
isRenewal: data.isRenewal ?? false,
employeeId: data.employeeId || null,
Expand Down
7 changes: 7 additions & 0 deletions src/app/api/submissions/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export async function GET(request: Request, context: RouteContext) {
try {
const submission = await prisma.submission.findUnique({
where: { id },
// Pull externalId from the linked Employee so partner-system
// callers (staff-portal sync) can match deterministically without
// a second round-trip.
include: { employee: { select: { externalId: true } } },
});

if (!submission) {
Expand All @@ -43,6 +47,9 @@ export async function GET(request: Request, context: RouteContext) {
return NextResponse.json({
...submission,
...decryptedPii,
// Lift externalId to the top of the response so callers don't
// need to dig through .employee. Null when no partner anchor.
externalId: submission.employee?.externalId ?? null,
});
} catch (err) {
console.error("Submission fetch error:", err);
Expand Down
47 changes: 43 additions & 4 deletions src/app/api/submissions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,38 @@ export async function POST(request: Request) {

if (invite.employeeId) {
employeeId = invite.employeeId;
} else if (invite.externalId) {
// Partner system gave us a stable identifier. If an Employee
// already exists with that externalId (e.g. a renewal — same
// person, new invite), reuse it. Otherwise create a new one
// and stamp the externalId so future syncs find it
// deterministically.
const existing = await prisma.employee.findUnique({
where: { externalId: invite.externalId },
});
if (existing) {
employeeId = existing.id;
} else {
const employee = await prisma.employee.create({
data: {
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
phone: data.phone || null,
workerType: invite.workerType ?? "employee",
externalId: invite.externalId,
},
});
employeeId = employee.id;
}
await prisma.invite.update({
where: { id: invite.id },
data: { employeeId },
});
} else {
// Create a new employee from submission data
// No externalId on the invite — standalone open-i9 install,
// or a partner that hasn't adopted the externalId path yet.
// Create a fresh Employee with no cross-system anchor.
const employee = await prisma.employee.create({
data: {
firstName: data.firstName,
Expand All @@ -165,8 +195,6 @@ export async function POST(request: Request) {
},
});
employeeId = employee.id;

// Link invite to new employee
await prisma.invite.update({
where: { id: invite.id },
data: { employeeId: employee.id },
Expand Down Expand Up @@ -299,13 +327,24 @@ export async function GET(request: Request) {
isRenewal: true,
employeeId: true,
nextRenewalDate: true,
// Partner-system stable ID (e.g. NyTex's "NTX-2053"). Joined
// through the linked Employee; null when there's no partner
// anchor on this submission.
employee: { select: { externalId: true } },
},
}),
prisma.submission.count({ where }),
]);

// Project externalId to the top-level of each row so partner-system
// sync callers don't have to dig through .employee.
const projected = submissions.map((s) => ({
...s,
externalId: s.employee?.externalId ?? null,
}));

return NextResponse.json({
submissions,
submissions: projected,
pagination: {
page,
limit,
Expand Down
19 changes: 19 additions & 0 deletions src/components/admin/SubmissionsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ export interface SubmissionRow {
docChoice: DocChoice;
status: SubmissionStatus;
createdAt: string;
// Stable cross-system anchor (e.g. NyTex's "NTX-2053"). Lifted to the
// top of the response by GET /api/submissions (joined through
// Employee.externalId). Null for submissions with no partner anchor.
externalId?: string | null;
}

interface SubmissionsTableProps {
Expand Down Expand Up @@ -68,6 +72,11 @@ export default function SubmissionsTable({
);
}

// Hide the External ID column entirely when no row carries a partner
// anchor — same approach as the Employees table. Standalone installs
// get the original five-column layout.
const hasExternalIds = submissions.some((s) => !!s.externalId);

return (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
Expand All @@ -77,6 +86,11 @@ export default function SubmissionsTable({
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
{hasExternalIds ? (
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
External ID
</th>
) : null}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Submitted
</th>
Expand All @@ -101,6 +115,11 @@ export default function SubmissionsTable({
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{sub.firstName} {sub.lastName}
</td>
{hasExternalIds ? (
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-700">
{sub.externalId ?? "—"}
</td>
) : null}
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(sub.createdAt).toLocaleDateString()}
</td>
Expand Down
Loading