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
80 changes: 80 additions & 0 deletions scripts/resolve-stranded-applications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env npx tsx
/**
* One-off backfill: resolve applications stranded on closed or filled gigs.
*
* Before PATCH /api/gigs/[id]/status started rejecting open applications when
* a gig is closed or filled, those applications stayed pending/reviewing/
* shortlisted forever. This script rejects them with metadata.reason
* 'gig_closed' (the same shape the route now writes), so "pending" means the
* application is still in play.
*
* Only closed and filled gigs: paused and draft gigs may come back. Applications
* held for spam review are left alone.
*
* It sends NO email (bulk backfill). The notify_on_application_status_change
* trigger still writes one in-app notification per application it rejects.
*
* Usage (env from the vault / the app env, not a .env file):
* npx tsx scripts/resolve-stranded-applications.ts # dry run: counts only
* npx tsx scripts/resolve-stranded-applications.ts --apply # write
*/

import { createClient } from "@supabase/supabase-js";
import { rejectOpenApplications } from "../src/lib/application-resolution";
import { OPEN_APPLICATION_STATUSES } from "../src/lib/application-status";
import { HELD_COLUMN } from "../src/lib/limits";

const APPLY = process.argv.includes("--apply");

async function main() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !key) throw new Error("NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be set");

const supabase = createClient(url, key, {
auth: { autoRefreshToken: false, persistSession: false },
});

console.log(`${APPLY ? "APPLY" : "DRY RUN"} against ${new URL(url).host}`);

// Closed/filled gigs that still have at least one open application.
const gigIds = new Set<string>();
const PAGE = 1000;
for (let from = 0; ; from += PAGE) {
const { data, error } = await supabase
.from("applications")
.select("gig_id, gig:gigs!inner(status)")
.in("status", [...OPEN_APPLICATION_STATUSES])
.in("gig.status", ["closed", "filled"])
.is(HELD_COLUMN, null)
.order("id", { ascending: true })
.range(from, from + PAGE - 1);
if (error) throw new Error(`stranded applications query failed: ${error.message}`);
for (const row of data ?? []) gigIds.add((row as { gig_id: string }).gig_id);
if (!data || data.length < PAGE) break;
}

let total = 0;
const byPreviousStatus: Record<string, number> = {};
for (const gigId of gigIds) {
const resolved = await rejectOpenApplications(supabase, gigId, "gig_closed", {
dryRun: !APPLY,
});
total += resolved.length;
for (const r of resolved) {
byPreviousStatus[r.previous_status] = (byPreviousStatus[r.previous_status] ?? 0) + 1;
}
}

console.log(`Gigs (closed/filled) with open applications: ${gigIds.size}`);
console.log(`Applications ${APPLY ? "rejected" : "that would be rejected"}: ${total}`);
for (const [status, count] of Object.entries(byPreviousStatus).sort()) {
console.log(` was ${status}: ${count}`);
}
if (!APPLY) console.log("Nothing written. Re-run with --apply to reject them.");
}

main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});
82 changes: 82 additions & 0 deletions src/app/api/applications/[id]/status/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ vi.mock("@/lib/reputation-hooks", () => ({
onHired: vi.fn(),
}));

vi.mock("@/lib/application-emails", () => ({
emailApplicantsAboutStatusInBackground: vi.fn(),
}));

import { getAuthContext } from "@/lib/auth/get-user";
import { emailApplicantsAboutStatusInBackground } from "@/lib/application-emails";
const mockEmailApplicants = vi.mocked(emailApplicantsAboutStatusInBackground);
const mockGetAuthContext = vi.mocked(getAuthContext);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -410,4 +416,80 @@ describe("PUT /api/applications/[id]/status", () => {
expect(res.status).toBe(200);
expect(json.application.status).toBe("reviewing");
});

describe("applicant status email", () => {
function posterUpdates(newStatus: string, current = mockApplication) {
mockGetAuthContext.mockResolvedValue({
user: { id: "poster-user-id", authMethod: "session" },
supabase: supabaseClient,
} as MockAuthContext);
let callCount = 0;
mockFrom.mockImplementation(() => {
callCount++;
if (callCount === 1) return chainResult({ data: current, error: null });
if (callCount === 2) return chainResult({ data: mockGig, error: null });
return chainResult({ data: { ...current, status: newStatus }, error: null });
});
}

it.each(["accepted", "rejected", "shortlisted"])(
"hands a %s change to the (gated) applicant email",
async (status) => {
posterUpdates(status);
const res = await PUT(makeRequest({ status }), routeParams);
expect(res.status).toBe(200);
expect(mockEmailApplicants).toHaveBeenCalledTimes(1);
expect(mockEmailApplicants).toHaveBeenCalledWith([
{
applicationId: "test-app-id",
applicantId: "applicant-user-id",
gigId: "test-gig-id",
gigTitle: "Test Gig",
posterName: "Test Poster",
status,
},
]);
}
);

it("does not email when the status did not change", async () => {
posterUpdates("rejected", { ...mockApplication, status: "rejected" });
const res = await PUT(makeRequest({ status: "rejected" }), routeParams);
expect(res.status).toBe(200);
expect(mockEmailApplicants).not.toHaveBeenCalled();
});

it("does not email the applicant about their own withdrawal", async () => {
mockGetAuthContext.mockResolvedValue({
user: { id: "applicant-user-id", authMethod: "session" },
supabase: supabaseClient,
} as MockAuthContext);
let callCount = 0;
mockFrom.mockImplementation(() => {
callCount++;
if (callCount === 1) return chainResult({ data: mockApplication, error: null });
if (callCount === 2) return chainResult({ data: mockGig, error: null });
return chainResult({ data: { ...mockApplication, status: "withdrawn" }, error: null });
});
await PUT(makeRequest({ status: "withdrawn" }), routeParams);
expect(mockEmailApplicants).not.toHaveBeenCalled();
});

it("does not email when the update fails", async () => {
mockGetAuthContext.mockResolvedValue({
user: { id: "poster-user-id", authMethod: "session" },
supabase: supabaseClient,
} as MockAuthContext);
let callCount = 0;
mockFrom.mockImplementation(() => {
callCount++;
if (callCount === 1) return chainResult({ data: mockApplication, error: null });
if (callCount === 2) return chainResult({ data: mockGig, error: null });
return chainResult({ data: null, error: { message: "Database error" } });
});
const res = await PUT(makeRequest({ status: "accepted" }), routeParams);
expect(res.status).toBe(400);
expect(mockEmailApplicants).not.toHaveBeenCalled();
});
});
});
17 changes: 17 additions & 0 deletions src/app/api/applications/[id]/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getAuthContext } from "@/lib/auth/get-user";
import { applicationStatusSchema } from "@/lib/validations";
import { getUserDid, onHired } from "@/lib/reputation-hooks";
import { triggerNotifiesStatus } from "@/lib/application-status";
import { emailApplicantsAboutStatusInBackground } from "@/lib/application-emails";

async function parseJsonBody(request: NextRequest) {
try {
Expand Down Expand Up @@ -144,6 +145,22 @@ export async function PUT(
// to send email notifications in the future.
}

// Email the applicant (gated on their email_application_status setting).
// Fire-and-forget: an email failure never fails the status change.
if (isPoster && !isApplicant && application.status !== status && gig) {
const poster = Array.isArray(gig.poster) ? gig.poster[0] : gig.poster;
emailApplicantsAboutStatusInBackground([
{
applicationId: id,
applicantId: application.applicant_id,
gigId: application.gig_id,
gigTitle: gig.title,
posterName: poster?.full_name || poster?.username || "The client",
status,
},
]);
}

return NextResponse.json({ application: updatedApplication });
} catch {
return NextResponse.json(
Expand Down
86 changes: 86 additions & 0 deletions src/app/api/applications/bulk-status/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ vi.mock("@/lib/auth/get-user", () => ({
getAuthContext: (...args: unknown[]) => mockGetAuthContext(...args),
}));

vi.mock("@/lib/application-emails", () => ({
emailApplicantsAboutStatusInBackground: vi.fn(),
}));
import { emailApplicantsAboutStatusInBackground } from "@/lib/application-emails";
const mockEmailApplicants = vi.mocked(emailApplicantsAboutStatusInBackground);

const mockFrom = vi.fn();

function makeRequest(body: string) {
Expand Down Expand Up @@ -84,4 +90,84 @@ describe("PUT /api/applications/bulk-status", () => {
expect(applicationsUpdate).not.toHaveBeenCalled();
expect(notificationsInsert).not.toHaveBeenCalled();
});

describe("applicant status emails", () => {
const idA = "11111111-1111-4111-8111-111111111111";
const idB = "22222222-2222-4222-8222-222222222222";
const gig = {
id: "gig-1",
poster_id: "poster-1",
title: "Landing page",
poster: { full_name: "Pat Poster", username: "pat" },
};

function setup(rows: { id: string; applicant_id: string; status: string }[], updatedIds: string[]) {
const applicationsIn = vi.fn().mockResolvedValue({
data: rows.map((r) => ({ ...r, gig_id: "gig-1", gig })),
error: null,
});
const updateSelect = vi.fn().mockResolvedValue({
data: updatedIds.map((id) => ({ id })),
error: null,
});
const update = vi.fn(() => ({ in: vi.fn(() => ({ select: updateSelect })) }));
const notificationsInsert = vi.fn().mockResolvedValue({ error: null });
mockFrom.mockImplementation((table: string) => {
if (table === "applications") {
return { select: vi.fn(() => ({ in: applicationsIn })), update };
}
if (table === "notifications") return { insert: notificationsInsert };
throw new Error(`Unexpected table: ${table}`);
});
return { notificationsInsert };
}

it("emails applicants whose status changed to rejected, and inserts no notifications", async () => {
const { notificationsInsert } = setup(
[
{ id: idA, applicant_id: "worker-a", status: "pending" },
{ id: idB, applicant_id: "worker-b", status: "rejected" },
],
[idA, idB]
);

const res = await PUT(
makeRequest(JSON.stringify({ application_ids: [idA, idB], status: "rejected" }))
);

expect(res.status).toBe(200);
// The DB trigger owns the in-app notification for rejected.
expect(notificationsInsert).not.toHaveBeenCalled();
expect(mockEmailApplicants).toHaveBeenCalledTimes(1);
// worker-b was already rejected, so only worker-a hears about it.
expect(mockEmailApplicants).toHaveBeenCalledWith([
{
applicationId: idA,
applicantId: "worker-a",
gigId: "gig-1",
gigTitle: "Landing page",
posterName: "Pat Poster",
status: "rejected",
},
]);
});

it("hands shortlisted changes to the email too", async () => {
setup([{ id: idA, applicant_id: "worker-a", status: "reviewing" }], [idA]);

await PUT(makeRequest(JSON.stringify({ application_ids: [idA], status: "shortlisted" })));

expect(mockEmailApplicants).toHaveBeenCalledWith([
expect.objectContaining({ applicantId: "worker-a", status: "shortlisted" }),
]);
});

it("skips applications the update did not touch", async () => {
setup([{ id: idA, applicant_id: "worker-a", status: "pending" }], []);

await PUT(makeRequest(JSON.stringify({ application_ids: [idA], status: "accepted" })));

expect(mockEmailApplicants).toHaveBeenCalledWith([]);
});
});
});
29 changes: 28 additions & 1 deletion src/app/api/applications/bulk-status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getAuthContext } from "@/lib/auth/get-user";
import { z } from "zod";
import { triggerNotifiesStatus } from "@/lib/application-status";
import { emailApplicantsAboutStatusInBackground } from "@/lib/application-emails";

const bulkStatusSchema = z.object({
application_ids: z.array(z.string().uuid()).min(1).max(50),
Expand Down Expand Up @@ -56,9 +57,12 @@ export async function PUT(request: NextRequest) {
id,
applicant_id,
gig_id,
status,
gig:gigs (
id,
poster_id
poster_id,
title,
poster:profiles!poster_id (full_name, username)
)
`
)
Expand Down Expand Up @@ -133,6 +137,29 @@ export async function PUT(request: NextRequest) {
await supabase.from("notifications").insert(notifications);
}

// Email applicants whose status actually changed (gated per applicant on
// email_application_status). Fire-and-forget.
const updatedIds = new Set((updatedApplications ?? []).map((a) => a.id));
emailApplicantsAboutStatusInBackground(
applications
.filter((app) => updatedIds.has(app.id) && app.status !== status)
.map((app) => {
const gig = (Array.isArray(app.gig) ? app.gig[0] : app.gig) as {
title?: string | null;
poster?: { full_name: string | null; username: string | null } | { full_name: string | null; username: string | null }[] | null;
} | null;
const poster = Array.isArray(gig?.poster) ? gig?.poster[0] : gig?.poster;
return {
applicationId: app.id,
applicantId: app.applicant_id,
gigId: app.gig_id,
gigTitle: gig?.title || "a gig",
posterName: poster?.full_name || poster?.username || "The client",
status,
};
})
);

return NextResponse.json({
updated: updatedApplications?.length || 0,
applications: updatedApplications,
Expand Down
Loading
Loading