From 41f1b4793a824eda120c2166cd7bde775d5a397b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 6 Oct 2026 10:28:50 +0000 Subject: [PATCH] feat(applications): status emails, poster digest, one-click unsubscribe, resolve applications on close/fill/delete - Applicant status email (accepted/rejected/shortlisted) from the single status route, bulk-status and approve-all, gated on email_application_status, fire-and-forget. In-app stays with the trigger. - New-application emails to posters are gated on email_new_application and sent instantly only for the first application on a gig in 24h; the rest go into a daily digest (POST /api/cron/application-digest, CRON_SECRET, ?dry_run=1). Sent applications are stamped in applications.metadata. - Signed one-click unsubscribe (UNSUBSCRIBE_SECRET, falls back to CRON_SECRET) with List-Unsubscribe/List-Unsubscribe-Post headers on every setting-tied email; GET/POST /api/email/unsubscribe turns that setting off. Comment, mention, follower, endorsement, review, upvote and gig-filled emails now also honour their setting. - Closing or filling a gig rejects its open applications with metadata.reason gig_closed/gig_filled. Deleting a gig is refused (409) with a hire or an unpaid sent invoice; otherwise open applicants get one in-app notice. - scripts/resolve-stranded-applications.ts backfill (dry run by default, no email). - Withdraw button in My Applications; multi-select Reject/Shortlist on the poster's applications page. Co-Authored-By: Claude Opus 5.5 --- scripts/resolve-stranded-applications.ts | 80 ++++ .../applications/[id]/status/route.test.ts | 82 ++++ src/app/api/applications/[id]/status/route.ts | 17 + .../applications/bulk-status/route.test.ts | 86 ++++ src/app/api/applications/bulk-status/route.ts | 29 +- src/app/api/applications/route.test.ts | 24 +- src/app/api/applications/route.ts | 54 +-- .../api/conversations/[id]/messages/route.ts | 1 + .../api/cron/application-digest/route.test.ts | 81 ++++ src/app/api/cron/application-digest/route.ts | 40 ++ src/app/api/email/unsubscribe/route.test.ts | 89 ++++ src/app/api/email/unsubscribe/route.ts | 79 ++++ .../applications/approve-all/route.test.ts | 128 ++++-- .../[id]/applications/approve-all/route.ts | 18 +- .../[id]/applications/message-all/route.ts | 6 +- .../api/gigs/[id]/applications/route.test.ts | 36 ++ src/app/api/gigs/[id]/applications/route.ts | 48 +-- .../gigs/[id]/comments/route.email.test.ts | 162 ++++++++ src/app/api/gigs/[id]/comments/route.ts | 13 +- src/app/api/gigs/[id]/route.test.ts | 128 ++++++ src/app/api/gigs/[id]/route.ts | 82 +++- .../gigs/[id]/status/route.resolve.test.ts | 170 ++++++++ src/app/api/gigs/[id]/status/route.ts | 43 +- src/app/api/messages/broadcast/route.test.ts | 5 + src/app/api/messages/broadcast/route.ts | 6 +- src/app/api/messages/send/route.ts | 1 + src/app/api/posts/[id]/comments/route.test.ts | 37 +- src/app/api/posts/[id]/comments/route.ts | 19 +- src/app/api/posts/[id]/upvote/route.test.ts | 95 +++++ src/app/api/posts/[id]/upvote/route.ts | 7 +- src/app/api/reviews/route.email.test.ts | 110 +++++ src/app/api/reviews/route.ts | 8 +- .../users/[username]/endorse/route.test.ts | 54 +++ src/app/api/users/[username]/endorse/route.ts | 7 +- .../api/users/[username]/follow/route.test.ts | 46 +++ src/app/api/users/[username]/follow/route.ts | 7 +- src/app/dashboard/applications/page.tsx | 10 +- src/app/gigs/[id]/applications/page.tsx | 281 ++++++------- .../BulkApplicationActions.test.tsx | 132 ++++++ .../applications/BulkApplicationActions.tsx | 160 ++++++++ .../WithdrawApplicationButton.tsx | 56 +++ src/lib/application-emails.test.ts | 320 +++++++++++++++ src/lib/application-emails.ts | 385 ++++++++++++++++++ src/lib/application-resolution.test.ts | 89 ++++ src/lib/application-resolution.ts | 80 ++++ src/lib/application-status.ts | 10 + src/lib/email-unsubscribe.test.ts | 193 +++++++++ src/lib/email-unsubscribe.ts | 82 ++++ src/lib/email.ts | 158 ++++++- src/lib/gigs/expire-gigs.test.ts | 1 + src/lib/gigs/expire-gigs.ts | 1 + 51 files changed, 3603 insertions(+), 253 deletions(-) create mode 100644 scripts/resolve-stranded-applications.ts create mode 100644 src/app/api/cron/application-digest/route.test.ts create mode 100644 src/app/api/cron/application-digest/route.ts create mode 100644 src/app/api/email/unsubscribe/route.test.ts create mode 100644 src/app/api/email/unsubscribe/route.ts create mode 100644 src/app/api/gigs/[id]/comments/route.email.test.ts create mode 100644 src/app/api/gigs/[id]/status/route.resolve.test.ts create mode 100644 src/app/api/posts/[id]/upvote/route.test.ts create mode 100644 src/app/api/reviews/route.email.test.ts create mode 100644 src/components/applications/BulkApplicationActions.test.tsx create mode 100644 src/components/applications/BulkApplicationActions.tsx create mode 100644 src/components/applications/WithdrawApplicationButton.tsx create mode 100644 src/lib/application-emails.test.ts create mode 100644 src/lib/application-emails.ts create mode 100644 src/lib/application-resolution.test.ts create mode 100644 src/lib/application-resolution.ts create mode 100644 src/lib/email-unsubscribe.test.ts create mode 100644 src/lib/email-unsubscribe.ts diff --git a/scripts/resolve-stranded-applications.ts b/scripts/resolve-stranded-applications.ts new file mode 100644 index 00000000..45daf96c --- /dev/null +++ b/scripts/resolve-stranded-applications.ts @@ -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(); + 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 = {}; + 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); +}); diff --git a/src/app/api/applications/[id]/status/route.test.ts b/src/app/api/applications/[id]/status/route.test.ts index 05fa37e5..d27a6d5d 100644 --- a/src/app/api/applications/[id]/status/route.test.ts +++ b/src/app/api/applications/[id]/status/route.test.ts @@ -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 @@ -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(); + }); + }); }); diff --git a/src/app/api/applications/[id]/status/route.ts b/src/app/api/applications/[id]/status/route.ts index c5de9f25..d42c7c9d 100644 --- a/src/app/api/applications/[id]/status/route.ts +++ b/src/app/api/applications/[id]/status/route.ts @@ -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 { @@ -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( diff --git a/src/app/api/applications/bulk-status/route.test.ts b/src/app/api/applications/bulk-status/route.test.ts index cc03341e..3afd4a2d 100644 --- a/src/app/api/applications/bulk-status/route.test.ts +++ b/src/app/api/applications/bulk-status/route.test.ts @@ -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) { @@ -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([]); + }); + }); }); diff --git a/src/app/api/applications/bulk-status/route.ts b/src/app/api/applications/bulk-status/route.ts index ff2b1bc6..c8c78297 100644 --- a/src/app/api/applications/bulk-status/route.ts +++ b/src/app/api/applications/bulk-status/route.ts @@ -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), @@ -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) ) ` ) @@ -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, diff --git a/src/app/api/applications/route.test.ts b/src/app/api/applications/route.test.ts index 9d9b6a9f..cc87e7c8 100644 --- a/src/app/api/applications/route.test.ts +++ b/src/app/api/applications/route.test.ts @@ -35,6 +35,10 @@ vi.mock("@/lib/email", () => ({ newApplicationEmail: vi.fn(() => ({ subject: "test", text: "test", html: "test" })), })); +vi.mock("@/lib/application-emails", () => ({ + notifyPosterOfNewApplicationInBackground: vi.fn(), +})); + vi.mock("@/lib/webhooks/dispatch", () => ({ dispatchWebhookAsync: vi.fn(), })); @@ -51,9 +55,10 @@ vi.mock("@/lib/limits", async (importOriginal) => ({ checkApplicationLimits: mockCheckApplicationLimits, })); -import { getAuthContext, createServiceClient } from "@/lib/auth/get-user"; +import { getAuthContext } from "@/lib/auth/get-user"; import { sendEmail } from "@/lib/email"; import { dispatchWebhookAsync } from "@/lib/webhooks/dispatch"; +import { notifyPosterOfNewApplicationInBackground } from "@/lib/application-emails"; const mockGetAuthContext = vi.mocked(getAuthContext); type MockAuthContext = any; @@ -154,6 +159,19 @@ describe("POST /api/applications - re-apply after withdrawal", () => { expect(insertCalls).toHaveLength(0); expect(updateCalls).toHaveLength(1); expect(updateCalls[0].status).toBe("pending"); + // The poster email goes through the instant-or-digest helper, never a + // direct per-application send. + expect(sendEmail).not.toHaveBeenCalled(); + expect(notifyPosterOfNewApplicationInBackground).toHaveBeenCalledWith({ + gigId: GIG_ID, + gigTitle: "Test Gig", + posterId: "poster-1", + posterName: "Poster", + applicationId: "app-existing", + applicationMetadata: undefined, + applicantName: "Applicant", + coverLetter: "x".repeat(60), + }); }); it("still blocks re-applying when an active (pending) application exists", async () => { @@ -272,7 +290,8 @@ describe("POST /api/applications - limits", () => { expect(inserts[0].status).toBeUndefined(); // DB default: pending expect(sendEmail).not.toHaveBeenCalled(); expect(dispatchWebhookAsync).not.toHaveBeenCalled(); - expect(createServiceClient).not.toHaveBeenCalled(); + // No instant email; held rows are also left out of the daily digest. + expect(notifyPosterOfNewApplicationInBackground).not.toHaveBeenCalled(); }); it("does not mark a normal application as held and still notifies the poster", async () => { @@ -283,6 +302,7 @@ describe("POST /api/applications - limits", () => { expect(res.status).toBe(201); expect(inserts[0].metadata).toBeUndefined(); expect(dispatchWebhookAsync).toHaveBeenCalledWith("poster-1", "application.new", expect.any(Object)); + expect(notifyPosterOfNewApplicationInBackground).toHaveBeenCalledTimes(1); }); it("excludes the withdrawn row from the duplicate check and holds the resubmission", async () => { diff --git a/src/app/api/applications/route.ts b/src/app/api/applications/route.ts index 9557f368..749b7662 100644 --- a/src/app/api/applications/route.ts +++ b/src/app/api/applications/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applicationSchema } from "@/lib/validations"; -import { sendEmail, newApplicationEmail } from "@/lib/email"; -import { getAuthContext, createServiceClient } from "@/lib/auth/get-user"; +import { notifyPosterOfNewApplicationInBackground } from "@/lib/application-emails"; +import { getAuthContext } from "@/lib/auth/get-user"; import { checkRateLimit, rateLimitExceeded, getRateLimitIdentifier } from "@/lib/rate-limit"; import { dispatchWebhookAsync } from "@/lib/webhooks/dispatch"; import { getUserDid, onApplicationSubmitted } from "@/lib/reputation-hooks"; @@ -160,37 +160,25 @@ export async function POST(request: NextRequest) { return NextResponse.json({ application }, { status: 201 }); } - // Send email notification to gig poster - // Get poster email from auth.users (not in profiles table) - const adminClient = createServiceClient(); - const { data: posterAuth } = await adminClient.auth.admin.getUserById(gig.poster_id); - const posterEmail = posterAuth?.user?.email; - - if (posterEmail) { - // Get applicant profile - const { data: applicantProfile } = await supabase - .from("profiles") - .select("full_name, username") - .eq("id", user.id) - .single(); - - const applicantName = applicantProfile?.full_name || applicantProfile?.username || "A candidate"; - const posterName = poster?.full_name || poster?.username || "there"; - - const emailContent = newApplicationEmail({ - posterName, - applicantName, - gigTitle: gig.title, - gigId: gig_id, - applicationId: application.id, - coverLetterPreview: applicationData.cover_letter, - }); - - await sendEmail({ - to: posterEmail, - ...emailContent, - }); - } + // Email the poster: instantly for the first application on this gig in + // the last 24h, otherwise via the daily digest. Gated on the poster's + // email_new_application setting. Fire-and-forget. + const { data: applicantProfile } = await supabase + .from("profiles") + .select("full_name, username") + .eq("id", user.id) + .single(); + + notifyPosterOfNewApplicationInBackground({ + gigId: gig_id, + gigTitle: gig.title, + posterId: gig.poster_id, + posterName: poster?.full_name || poster?.username || "there", + applicationId: application.id, + applicationMetadata: (application as { metadata?: unknown }).metadata, + applicantName: applicantProfile?.full_name || applicantProfile?.username || "A candidate", + coverLetter: applicationData.cover_letter, + }); // Log activity void logActivity(supabase, { diff --git a/src/app/api/conversations/[id]/messages/route.ts b/src/app/api/conversations/[id]/messages/route.ts index 8f7cba93..070158b8 100644 --- a/src/app/api/conversations/[id]/messages/route.ts +++ b/src/app/api/conversations/[id]/messages/route.ts @@ -288,6 +288,7 @@ export async function POST( sendEmail({ to: recipientEmail, ...emailContent, + unsubscribe: { userId: recipientId, setting: "email_new_message" }, }).catch((err) => console.error("Failed to send message notification email:", err) ); diff --git a/src/app/api/cron/application-digest/route.test.ts b/src/app/api/cron/application-digest/route.test.ts new file mode 100644 index 00000000..88f1dd9a --- /dev/null +++ b/src/app/api/cron/application-digest/route.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +const svc = { tag: "service-client" }; +vi.mock("@/lib/supabase/service", () => ({ + createServiceClient: vi.fn(() => svc), +})); + +vi.mock("@/lib/application-emails", () => ({ + runApplicationDigest: vi.fn(), +})); + +import { POST } from "./route"; +import { runApplicationDigest } from "@/lib/application-emails"; +const mockRun = vi.mocked(runApplicationDigest); + +function makeRequest(headers: Record = {}, query = "") { + return new NextRequest(`http://localhost/api/cron/application-digest${query}`, { + method: "POST", + headers, + }); +} + +const sample = { + dry_run: false, + window_start: "2026-10-05T08:00:00.000Z", + window_end: "2026-10-06T08:00:00.000Z", + posters: [{ poster_id: "p1", gigs: 1, applications: 3, outcome: "sent" as const }], + totals: { applications: 3, posters: 1, sent: 1, would_send: 0, skipped: 0 }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("CRON_SECRET", "cron-test-secret"); + mockRun.mockResolvedValue(sample); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("POST /api/cron/application-digest", () => { + it("returns 401 without the cron secret", async () => { + const res = await POST(makeRequest()); + expect(res.status).toBe(401); + expect(mockRun).not.toHaveBeenCalled(); + }); + + it("returns 401 with a wrong secret", async () => { + const res = await POST(makeRequest({ "x-cron-secret": "nope" })); + expect(res.status).toBe(401); + }); + + it("returns 401 when CRON_SECRET is not configured", async () => { + vi.stubEnv("CRON_SECRET", ""); + const res = await POST(makeRequest({ "x-cron-secret": "" })); + expect(res.status).toBe(401); + }); + + it("runs the digest for real with x-cron-secret", async () => { + const res = await POST(makeRequest({ "x-cron-secret": "cron-test-secret" })); + expect(res.status).toBe(200); + expect(mockRun).toHaveBeenCalledWith(svc, { dryRun: false }); + expect(await res.json()).toEqual(sample); + }); + + it("accepts Authorization: Bearer and passes ?dry_run=1 through", async () => { + mockRun.mockResolvedValue({ ...sample, dry_run: true }); + const res = await POST(makeRequest({ authorization: "Bearer cron-test-secret" }, "?dry_run=1")); + expect(res.status).toBe(200); + expect(mockRun).toHaveBeenCalledWith(svc, { dryRun: true }); + expect((await res.json()).dry_run).toBe(true); + }); + + it("returns 500 when the digest fails", async () => { + mockRun.mockRejectedValue(new Error("applications query failed: boom")); + const res = await POST(makeRequest({ "x-cron-secret": "cron-test-secret" })); + expect(res.status).toBe(500); + expect((await res.json()).error).toContain("boom"); + }); +}); diff --git a/src/app/api/cron/application-digest/route.ts b/src/app/api/cron/application-digest/route.ts new file mode 100644 index 00000000..1c641c58 --- /dev/null +++ b/src/app/api/cron/application-digest/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServiceClient } from "@/lib/supabase/service"; +import { runApplicationDigest } from "@/lib/application-emails"; + +/** + * Cron: daily new-applications digest for gig posters. + * + * Each poster gets one email covering the applications received in the last + * 24 hours that were not already emailed instantly (the first application on + * a gig in a 24h window goes out at once; see notifyPosterOfNewApplication). + * Honours each poster's email_new_application setting. Applications that make + * it into a digest are stamped in metadata, so a repeat run sends nothing new. + * + * Schedule: once a day. + * Auth: CRON_SECRET via x-cron-secret or Authorization: Bearer. + * ?dry_run=1 returns what would be sent and sends nothing. + */ +export async function POST(request: NextRequest) { + const cronSecret = + request.headers.get("x-cron-secret") || + request.headers.get("authorization")?.replace("Bearer ", ""); + if (!process.env.CRON_SECRET || !cronSecret || cronSecret !== process.env.CRON_SECRET) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const dryRun = request.nextUrl.searchParams.get("dry_run") === "1"; + + try { + const result = await runApplicationDigest(createServiceClient(), { dryRun }); + console.log( + `[application-digest] ${dryRun ? "dry run" : "run"}: ${result.totals.applications} applications, ` + + `${result.totals.posters} posters, sent ${result.totals.sent}, would send ${result.totals.would_send}, skipped ${result.totals.skipped}` + ); + return NextResponse.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error("[application-digest] failed:", message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/email/unsubscribe/route.test.ts b/src/app/api/email/unsubscribe/route.test.ts new file mode 100644 index 00000000..d2d879bb --- /dev/null +++ b/src/app/api/email/unsubscribe/route.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +const upsert = vi.fn(); +const from = vi.fn(() => ({ upsert })); + +vi.mock("@/lib/auth/get-user", () => ({ + createServiceClient: vi.fn(() => ({ from })), +})); + +import { GET, POST } from "./route"; +import { createUnsubscribeToken } from "@/lib/email-unsubscribe"; + +const USER = "11111111-2222-4333-8444-555555555555"; + +function req(method: "GET" | "POST", token: string | null) { + const url = new URL("http://localhost/api/email/unsubscribe"); + if (token !== null) url.searchParams.set("token", token); + return new NextRequest(url, { + method, + ...(method === "POST" + ? { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "List-Unsubscribe=One-Click", + } + : {}), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("UNSUBSCRIBE_SECRET", "unsub-test-secret"); + upsert.mockResolvedValue({ error: null }); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("/api/email/unsubscribe", () => { + it("GET turns the named setting off and shows a confirmation page", async () => { + const token = createUnsubscribeToken(USER, "email_application_status")!; + const res = await GET(req("GET", token)); + const html = await res.text(); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(html).toContain("You're unsubscribed"); + expect(html).toContain("application status emails"); + expect(from).toHaveBeenCalledWith("notification_settings"); + expect(upsert).toHaveBeenCalledWith( + expect.objectContaining({ user_id: USER, email_application_status: false }), + { onConflict: "user_id" } + ); + // Only that one setting is touched. + const row = upsert.mock.calls[0][0] as Record; + expect(Object.keys(row).filter((k) => k.startsWith("email_"))).toEqual(["email_application_status"]); + }); + + it("POST (RFC 8058 one-click) does the same", async () => { + const token = createUnsubscribeToken(USER, "email_new_application")!; + const res = await POST(req("POST", token)); + expect(res.status).toBe(200); + expect(upsert).toHaveBeenCalledWith( + expect.objectContaining({ user_id: USER, email_new_application: false }), + { onConflict: "user_id" } + ); + }); + + it("rejects a tampered token without writing", async () => { + const token = createUnsubscribeToken(USER, "email_new_message")!; + const res = await GET(req("GET", token.slice(0, -2) + "xx")); + expect(res.status).toBe(400); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("rejects a missing token without writing", async () => { + const res = await GET(req("GET", null)); + expect(res.status).toBe(400); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("answers 500 when the settings write fails", async () => { + upsert.mockResolvedValue({ error: { message: "db down" } }); + const token = createUnsubscribeToken(USER, "email_mention")!; + const res = await GET(req("GET", token)); + expect(res.status).toBe(500); + }); +}); diff --git a/src/app/api/email/unsubscribe/route.ts b/src/app/api/email/unsubscribe/route.ts new file mode 100644 index 00000000..79c9a737 --- /dev/null +++ b/src/app/api/email/unsubscribe/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServiceClient } from "@/lib/auth/get-user"; +import { UNSUBSCRIBABLE_SETTINGS, verifyUnsubscribeToken } from "@/lib/email-unsubscribe"; + +/** + * GET/POST /api/email/unsubscribe?token=... + * + * One-click unsubscribe from a single notification email setting. The token + * is HMAC-signed (src/lib/email-unsubscribe.ts) and names the user and the + * setting, so no login is needed. POST is the RFC 8058 one-click request mail + * clients send from the List-Unsubscribe-Post header; GET is the link in the + * email footer. Both turn the setting off and answer with a plain page. + */ + +function page(title: string, message: string, status = 200) { + const html = ` + + + + + + ${title} | ugig.net + + +

${title}

+

${message}

+

Manage all notification settings

+ +`; + return new NextResponse(html, { + status, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }, + }); +} + +async function handle(request: NextRequest) { + const parsed = verifyUnsubscribeToken(request.nextUrl.searchParams.get("token")); + if (!parsed) { + return page( + "Link not valid", + "This unsubscribe link is invalid or incomplete. You can change your email settings from your dashboard.", + 400 + ); + } + + const { userId, setting } = parsed; + const svc = createServiceClient(); + const { error } = await svc + .from("notification_settings") + .upsert( + { user_id: userId, [setting]: false, updated_at: new Date().toISOString() }, + { onConflict: "user_id" } + ); + + if (error) { + console.error("[unsubscribe] failed to update setting:", { setting, error: error.message }); + return page( + "Something went wrong", + "We could not update your email settings. Please try again, or change them from your dashboard.", + 500 + ); + } + + return page( + "You're unsubscribed", + `You will no longer receive ${UNSUBSCRIBABLE_SETTINGS[setting]} from ugig.net. Other emails are not affected.` + ); +} + +export async function GET(request: NextRequest) { + return handle(request); +} + +export async function POST(request: NextRequest) { + return handle(request); +} diff --git a/src/app/api/gigs/[id]/applications/approve-all/route.test.ts b/src/app/api/gigs/[id]/applications/approve-all/route.test.ts index 41bd52bd..1cf678bc 100644 --- a/src/app/api/gigs/[id]/applications/approve-all/route.test.ts +++ b/src/app/api/gigs/[id]/applications/approve-all/route.test.ts @@ -1,42 +1,120 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +import { POST } from "./route"; + +const svcFrom = vi.fn(); -const { mockFrom } = vi.hoisted(() => ({ mockFrom: vi.fn() })); vi.mock("@/lib/auth/get-user", () => ({ getAuthContext: vi.fn(), - createServiceClient: vi.fn(() => ({ from: mockFrom })), + createServiceClient: vi.fn(() => ({ from: svcFrom })), +})); + +vi.mock("@/lib/application-emails", () => ({ + emailApplicantsAboutStatusInBackground: vi.fn(), })); -import { POST } from "./route"; import { getAuthContext } from "@/lib/auth/get-user"; +import { emailApplicantsAboutStatusInBackground } from "@/lib/application-emails"; +const mockGetAuthContext = vi.mocked(getAuthContext); +const mockEmailApplicants = vi.mocked(emailApplicantsAboutStatusInBackground); + +const routeParams = { params: Promise.resolve({ id: "gig-1" }) }; +const request = () => + new NextRequest("http://localhost/api/gigs/gig-1/applications/approve-all", { method: "POST" }); -const params = { params: Promise.resolve({ id: "gig-1" }) }; +function gigChain(gig: unknown) { + const chain: Record> = {}; + chain.select = vi.fn(() => chain); + chain.eq = vi.fn(() => chain); + chain.single = vi.fn().mockResolvedValue({ data: gig, error: null }); + return chain; +} -beforeEach(() => vi.clearAllMocks()); +function updateChain(result: { data: unknown; error: unknown }) { + const chain: Record> = {}; + chain.update = vi.fn(() => chain); + chain.eq = vi.fn(() => chain); + chain.is = vi.fn(() => chain); + chain.select = vi.fn().mockResolvedValue(result); + return chain; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); describe("POST /api/gigs/[id]/applications/approve-all", () => { - it("never approves applications held for spam review", async () => { - const calls: [string, unknown[]][] = []; - mockFrom.mockImplementation((table: string) => { - const chain: Record> = {}; - for (const m of ["select", "eq", "update", "is", "single"]) { - chain[m] = vi.fn((...args: unknown[]) => { - if (table === "applications") calls.push([m, args]); - return chain; - }); - } - if (table === "gigs") { - chain.single.mockResolvedValue({ data: { poster_id: "owner-1" }, error: null }); - } else { - chain.select.mockResolvedValue({ data: [{ id: "a1" }], error: null }); - } - return chain; + it("returns 401 when not authenticated", async () => { + mockGetAuthContext.mockResolvedValue(null); + const res = await POST(request(), routeParams); + expect(res.status).toBe(401); + }); + + it("returns 403 for someone else's gig and emails nobody", async () => { + mockGetAuthContext.mockResolvedValue({ user: { id: "intruder" } } as never); + svcFrom.mockReturnValue(gigChain({ poster_id: "poster-1", title: "Gig", poster: null })); + const res = await POST(request(), routeParams); + expect(res.status).toBe(403); + expect(mockEmailApplicants).not.toHaveBeenCalled(); + }); + + it("accepts every pending application and emails each applicant (gated)", async () => { + mockGetAuthContext.mockResolvedValue({ user: { id: "poster-1" } } as never); + const update = updateChain({ + data: [ + { id: "app-1", applicant_id: "worker-1" }, + { id: "app-2", applicant_id: "worker-2" }, + ], + error: null, }); - vi.mocked(getAuthContext).mockResolvedValue({ user: { id: "owner-1" }, supabase: {} } as never); + svcFrom.mockImplementation((table: string) => + table === "gigs" + ? gigChain({ + poster_id: "poster-1", + title: "Data labelling", + poster: { full_name: null, username: "poster" }, + }) + : update + ); + + const res = await POST(request(), routeParams); + const json = await res.json(); - const res = await POST(new NextRequest("http://localhost/x", { method: "POST" }), params); expect(res.status).toBe(200); - expect(calls).toContainEqual(["is", ["metadata->>held", null]]); - expect(calls).toContainEqual(["eq", ["status", "pending"]]); + expect(json.approved).toBe(2); + expect(update.update).toHaveBeenCalledWith({ status: "accepted" }); + expect(update.eq).toHaveBeenCalledWith("status", "pending"); + // Applications held for spam review are never approved (or emailed). + expect(update.is).toHaveBeenCalledWith("metadata->>held", null); + expect(mockEmailApplicants).toHaveBeenCalledWith([ + { + applicationId: "app-1", + applicantId: "worker-1", + gigId: "gig-1", + gigTitle: "Data labelling", + posterName: "poster", + status: "accepted", + }, + { + applicationId: "app-2", + applicantId: "worker-2", + gigId: "gig-1", + gigTitle: "Data labelling", + posterName: "poster", + status: "accepted", + }, + ]); + }); + + it("emails nobody when the update fails", async () => { + mockGetAuthContext.mockResolvedValue({ user: { id: "poster-1" } } as never); + svcFrom.mockImplementation((table: string) => + table === "gigs" + ? gigChain({ poster_id: "poster-1", title: "Gig", poster: null }) + : updateChain({ data: null, error: { message: "nope" } }) + ); + const res = await POST(request(), routeParams); + expect(res.status).toBe(400); + expect(mockEmailApplicants).not.toHaveBeenCalled(); }); }); diff --git a/src/app/api/gigs/[id]/applications/approve-all/route.ts b/src/app/api/gigs/[id]/applications/approve-all/route.ts index aecf434f..ee2ad8b9 100644 --- a/src/app/api/gigs/[id]/applications/approve-all/route.ts +++ b/src/app/api/gigs/[id]/applications/approve-all/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getAuthContext, createServiceClient } from "@/lib/auth/get-user"; import { HELD_COLUMN } from "@/lib/limits"; +import { emailApplicantsAboutStatusInBackground } from "@/lib/application-emails"; // POST /api/gigs/[id]/applications/approve-all // Approves all pending applications for a gig in one shot. @@ -19,7 +20,7 @@ export async function POST( // Verify the caller is the gig poster const { data: gig } = await svc .from("gigs") - .select("poster_id") + .select("poster_id, title, poster:profiles!poster_id(full_name, username)") .eq("id", gigId) .single(); @@ -33,10 +34,23 @@ export async function POST( .eq("gig_id", gigId) .eq("status", "pending") .is(HELD_COLUMN, null) - .select("id"); + .select("id, applicant_id"); if (error) return NextResponse.json({ error: error.message }, { status: 400 }); + // Email each accepted applicant (gated on email_application_status). + const poster = Array.isArray(gig.poster) ? gig.poster[0] : gig.poster; + emailApplicantsAboutStatusInBackground( + (data ?? []).map((app) => ({ + applicationId: app.id, + applicantId: app.applicant_id, + gigId, + gigTitle: gig.title || "a gig", + posterName: poster?.full_name || poster?.username || "The client", + status: "accepted", + })) + ); + return NextResponse.json({ approved: (data ?? []).length }); } catch { return NextResponse.json({ error: "Unexpected error" }, { status: 500 }); diff --git a/src/app/api/gigs/[id]/applications/message-all/route.ts b/src/app/api/gigs/[id]/applications/message-all/route.ts index 0d3fc17b..f0790dd6 100644 --- a/src/app/api/gigs/[id]/applications/message-all/route.ts +++ b/src/app/api/gigs/[id]/applications/message-all/route.ts @@ -227,7 +227,11 @@ export async function POST( gigTitle: gig.title, }); - sendEmail({ to: recipientEmail, ...emailContent }).catch((err) => + sendEmail({ + to: recipientEmail, + ...emailContent, + unsubscribe: { userId: recipientId, setting: "email_new_message" }, + }).catch((err) => console.error("Failed to send broadcast message email:", err) ); } diff --git a/src/app/api/gigs/[id]/applications/route.test.ts b/src/app/api/gigs/[id]/applications/route.test.ts index a67f96aa..2d9f31ce 100644 --- a/src/app/api/gigs/[id]/applications/route.test.ts +++ b/src/app/api/gigs/[id]/applications/route.test.ts @@ -39,6 +39,10 @@ vi.mock("@/lib/email", () => ({ newApplicationEmail: vi.fn(() => ({ subject: "test", text: "test", html: "test" })), })); +vi.mock("@/lib/application-emails", () => ({ + notifyPosterOfNewApplicationInBackground: vi.fn(), +})); + vi.mock("@/lib/webhooks/dispatch", () => ({ dispatchWebhookAsync: vi.fn(), })); @@ -58,6 +62,7 @@ vi.mock("@/lib/limits", async (importOriginal) => ({ import { getAuthContext } from "@/lib/auth/get-user"; import { sendEmail } from "@/lib/email"; import { dispatchWebhookAsync } from "@/lib/webhooks/dispatch"; +import { notifyPosterOfNewApplicationInBackground } from "@/lib/application-emails"; const mockGetAuthContext = vi.mocked(getAuthContext); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -197,6 +202,36 @@ describe("POST /api/gigs/[id]/applications - success paths", () => { const body = await res.json(); expect(body.application).toBeDefined(); expect(body.application.id).toBe("app-1"); + expect(sendEmail).not.toHaveBeenCalled(); + expect(notifyPosterOfNewApplicationInBackground).toHaveBeenCalledTimes(1); + expect(notifyPosterOfNewApplicationInBackground).toHaveBeenCalledWith( + expect.objectContaining({ + gigId: "00000000-0000-4000-a000-000000000001", + posterId: "poster-1", + applicationId: "app-1", + applicantName: "Applicant", + }) + ); + }); + + it("does not email the poster when the application is refused", async () => { + mockFrom.mockImplementation((table: string) => { + if (table === "gigs") { + return chainResult({ + data: { poster_id: "poster-1", status: "closed", title: "Test Gig", poster: null }, + error: null, + }); + } + return chainResult({ data: null, error: null }); + }); + mockGetAuthContext.mockResolvedValue({ + user: { id: "user-1", authMethod: "api_key" }, + supabase: supabaseClient, + } as MockAuthContext); + + const res = await POST(makeRequest({ cover_letter: "x".repeat(60) }), routeParams); + expect(res.status).toBe(400); + expect(notifyPosterOfNewApplicationInBackground).not.toHaveBeenCalled(); }); it("returns 400 when already applied", async () => { @@ -309,6 +344,7 @@ describe("POST /api/gigs/[id]/applications - limits", () => { expect(inserts[0].metadata).toEqual({ held: "spam_review" }); expect(sendEmail).not.toHaveBeenCalled(); expect(dispatchWebhookAsync).not.toHaveBeenCalled(); + expect(notifyPosterOfNewApplicationInBackground).not.toHaveBeenCalled(); }); }); diff --git a/src/app/api/gigs/[id]/applications/route.ts b/src/app/api/gigs/[id]/applications/route.ts index d33e2c6f..71233129 100644 --- a/src/app/api/gigs/[id]/applications/route.ts +++ b/src/app/api/gigs/[id]/applications/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; -import { getAuthContext, createServiceClient } from "@/lib/auth/get-user"; +import { getAuthContext } from "@/lib/auth/get-user"; import { applicationSchema } from "@/lib/validations"; -import { sendEmail, newApplicationEmail } from "@/lib/email"; +import { notifyPosterOfNewApplicationInBackground } from "@/lib/application-emails"; import { checkRateLimit, rateLimitExceeded, getRateLimitIdentifier } from "@/lib/rate-limit"; import { dispatchWebhookAsync } from "@/lib/webhooks/dispatch"; import { getUserDid, onApplicationSubmitted } from "@/lib/reputation-hooks"; @@ -192,31 +192,25 @@ export async function POST( return NextResponse.json({ application }, { status: 201 }); } - const adminClient = createServiceClient(); - const { data: posterAuth } = await adminClient.auth.admin.getUserById(gig.poster_id); - const posterEmail = posterAuth?.user?.email; - - if (posterEmail) { - const { data: applicantProfile } = await supabase - .from("profiles") - .select("full_name, username") - .eq("id", user.id) - .single(); - - const applicantName = applicantProfile?.full_name || applicantProfile?.username || "A candidate"; - const posterName = poster?.full_name || poster?.username || "there"; - - const emailContent = newApplicationEmail({ - posterName, - applicantName, - gigTitle: gig.title, - gigId: gig_id, - applicationId: application.id, - coverLetterPreview: applicationData.cover_letter, - }); - - await sendEmail({ to: posterEmail, ...emailContent }); - } + // Email the poster: instantly for the first application on this gig in + // the last 24h, otherwise via the daily digest. Gated on the poster's + // email_new_application setting. Fire-and-forget. + const { data: applicantProfile } = await supabase + .from("profiles") + .select("full_name, username") + .eq("id", user.id) + .single(); + + notifyPosterOfNewApplicationInBackground({ + gigId: gig_id, + gigTitle: gig.title, + posterId: gig.poster_id, + posterName: poster?.full_name || poster?.username || "there", + applicationId: application.id, + applicationMetadata: (application as { metadata?: unknown }).metadata, + applicantName: applicantProfile?.full_name || applicantProfile?.username || "A candidate", + coverLetter: applicationData.cover_letter, + }); void logActivity(supabase, { userId: user.id, diff --git a/src/app/api/gigs/[id]/comments/route.email.test.ts b/src/app/api/gigs/[id]/comments/route.email.test.ts new file mode 100644 index 00000000..c64177d2 --- /dev/null +++ b/src/app/api/gigs/[id]/comments/route.email.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + getAuthContext: vi.fn(), + usersAreBlocked: vi.fn(), + sendEmail: vi.fn(), + emailEnabled: vi.fn(), +})); + +vi.mock("@/lib/auth/get-user", () => ({ + getAuthContext: mocks.getAuthContext, +})); + +vi.mock("@/lib/blocks", () => ({ + usersAreBlocked: mocks.usersAreBlocked, +})); + +vi.mock("@/lib/email", () => ({ + sendEmail: mocks.sendEmail, + newGigCommentEmail: () => ({ subject: "s", html: "h", text: "t" }), + newGigCommentReplyEmail: () => ({ subject: "s", html: "h", text: "t" }), +})); + +vi.mock("@/lib/supabase/service", () => ({ + createServiceClient: () => ({ + auth: { admin: { getUserById: async (id: string) => ({ data: { user: { id, email: `${id}@example.test` } } }) } }, + }), +})); + +vi.mock("@/lib/notification-settings", () => ({ + isEmailNotificationEnabled: mocks.emailEnabled, +})); + +vi.mock("@/lib/reputation-hooks", () => ({ + getUserDid: async () => null, + onCommentCreated: () => {}, +})); + +vi.mock("@/lib/activity", () => ({ logActivity: async () => {} })); + +vi.mock("@/lib/supabase/server", () => ({ + createClient: async () => makeSupabase(), +})); + +import { POST } from "./route"; + +const ME = "00000000-0000-4000-a000-00000000000a"; +const POSTER = "00000000-0000-4000-a000-00000000000b"; +const OTHER_COMMENTER = "00000000-0000-4000-a000-00000000000c"; +const GIG = "00000000-0000-4000-a000-0000000000ff"; +const PARENT = "00000000-0000-4000-a000-0000000000fe"; + +const inserted: Record[] = []; + +/** + * Enough of a Supabase client for the comment paths that send email. + */ +function makeSupabase({ parentAuthorId = OTHER_COMMENTER } = {}) { + return { + from(table: string) { + const chain: Record = {}; + chain.select = () => chain; + chain.eq = () => chain; + chain.order = () => chain; + chain.insert = (row: Record) => { + inserted.push({ table, ...row }); + return chain; + }; + chain.single = () => { + if (table === "gigs") { + return Promise.resolve({ + data: { + id: GIG, + title: "A gig", + poster_id: POSTER, + poster: { id: POSTER, username: "poster", full_name: null, avatar_url: null }, + }, + error: null, + }); + } + if (table === "gig_comments") { + return Promise.resolve({ + data: { + id: PARENT, + gig_id: GIG, + parent_id: null, + author_id: parentAuthorId, + content: "hi", + author: { id: parentAuthorId, username: "them", full_name: null, avatar_url: null }, + }, + error: null, + }); + } + return Promise.resolve({ data: null, error: null }); + }; + chain.then = (resolve: (value: unknown) => unknown) => + resolve({ data: null, error: null }); + return chain; + }, + }; +} + +function post(body: Record) { + return POST( + new NextRequest("http://localhost/api/gigs/x/comments", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }), + { params: Promise.resolve({ id: GIG }) } + ); +} + +describe("gig comment emails honour notification settings", () => { + beforeEach(() => { + inserted.length = 0; + mocks.sendEmail.mockReset(); + mocks.sendEmail.mockResolvedValue({ success: true }); + mocks.emailEnabled.mockReset(); + mocks.emailEnabled.mockResolvedValue(true); + mocks.usersAreBlocked.mockResolvedValue(false); + mocks.getAuthContext.mockResolvedValue({ + user: { id: ME }, + supabase: makeSupabase(), + }); + }); + + it("emails the gig poster about a new question, with an email_new_comment unsubscribe", async () => { + const res = await post({ content: "Still hiring?" }); + + expect(res.status).toBe(201); + expect(mocks.emailEnabled).toHaveBeenCalledWith(expect.anything(), POSTER, "email_new_comment"); + expect(mocks.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: `${POSTER}@example.test`, + unsubscribe: { userId: POSTER, setting: "email_new_comment" }, + }) + ); + }); + + it("emails the parent commenter about a reply, with an unsubscribe", async () => { + const res = await post({ content: "Agreed", parent_id: PARENT }); + + expect(res.status).toBe(201); + expect(mocks.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: `${OTHER_COMMENTER}@example.test`, + unsubscribe: { userId: OTHER_COMMENTER, setting: "email_new_comment" }, + }) + ); + }); + + it("sends nothing to someone who turned email_new_comment off", async () => { + mocks.emailEnabled.mockResolvedValue(false); + + const res = await post({ content: "Still hiring?" }); + + expect(res.status).toBe(201); + expect(mocks.sendEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/gigs/[id]/comments/route.ts b/src/app/api/gigs/[id]/comments/route.ts index 8710be60..acfcebe0 100644 --- a/src/app/api/gigs/[id]/comments/route.ts +++ b/src/app/api/gigs/[id]/comments/route.ts @@ -5,6 +5,7 @@ import { getAuthContext } from "@/lib/auth/get-user"; import { gigCommentSchema } from "@/lib/validations"; import { sanitizeContent } from "@/lib/sanitize"; import { sendEmail, newGigCommentEmail, newGigCommentReplyEmail } from "@/lib/email"; +import { isEmailNotificationEnabled } from "@/lib/notification-settings"; import { getUserDid, onCommentCreated } from "@/lib/reputation-hooks"; import { logActivity } from "@/lib/activity"; import { usersAreBlocked, getBlockedUserIds, excludeBlocked } from "@/lib/blocks"; @@ -263,7 +264,10 @@ export async function POST( .then(() => {}, () => {}); // Send email notification - if (parentAuthorAuth?.user?.email) { + if ( + parentAuthorAuth?.user?.email && + (await isEmailNotificationEnabled(svc, parentComment.author_id, "email_new_comment")) + ) { const { data: parentAuthorProfile } = await supabase .from("profiles") .select("full_name, username") @@ -281,6 +285,7 @@ export async function POST( void sendEmail({ to: parentAuthorAuth.user.email, ...emailContent, + unsubscribe: { userId: parentComment.author_id, setting: "email_new_comment" }, }); } } @@ -306,7 +311,10 @@ export async function POST( .then(() => {}, () => {}); // Send email notification - if (posterAuth?.user?.email) { + if ( + posterAuth?.user?.email && + (await isEmailNotificationEnabled(svc, gig.poster_id, "email_new_comment")) + ) { const posterName = poster?.full_name || poster?.username || "there"; const emailContent = newGigCommentEmail({ @@ -319,6 +327,7 @@ export async function POST( void sendEmail({ to: posterAuth.user.email, ...emailContent, + unsubscribe: { userId: gig.poster_id, setting: "email_new_comment" }, }); } } diff --git a/src/app/api/gigs/[id]/route.test.ts b/src/app/api/gigs/[id]/route.test.ts index a6b6d215..d320e320 100644 --- a/src/app/api/gigs/[id]/route.test.ts +++ b/src/app/api/gigs/[id]/route.test.ts @@ -20,8 +20,31 @@ vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn(() => Promise.resolve(supabaseClient)), })); +// Service client: per-table queues of results, one shifted per .from() call. +const svcResults: Record = {}; +const svcCalls: { table: string; chain: Record> }[] = []; +function svcThenable(result: unknown) { + const chain: Record> & { then?: unknown } = {}; + for (const m of ["select", "insert", "eq", "in", "neq", "is"]) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) => + Promise.resolve(result).then(resolve, reject); + return chain; +} +const svcClient = { + from: vi.fn((table: string) => { + const queue = svcResults[table] ?? []; + const result = queue.length > 0 ? queue.shift() : { data: null, error: null, count: 0 }; + const chain = svcThenable(result); + svcCalls.push({ table, chain: chain as Record> }); + return chain; + }), +}; + vi.mock("@/lib/auth/get-user", () => ({ getAuthContext: vi.fn(), + createServiceClient: vi.fn(() => svcClient), })); const { mockAdActivation } = vi.hoisted(() => ({ mockAdActivation: vi.fn() })); @@ -65,6 +88,8 @@ function chainResult(result: { data: unknown; error: unknown }) { beforeEach(() => { vi.clearAllMocks(); + for (const k of Object.keys(svcResults)) delete svcResults[k]; + svcCalls.length = 0; }); // ════════════════════════════════════════════════════════════════════ @@ -413,6 +438,109 @@ describe("DELETE /api/gigs/[id]", () => { expect(res.status).toBe(200); expect(json.message).toBe("Gig deleted successfully"); }); + + function ownerThenDelete(deleteResult: { error: unknown } = { error: null }) { + const ownerChain = chainResult({ data: { poster_id: userId, title: "Logo design" }, error: null }); + const deleteChain: Record> = {}; + for (const m of ["delete", "eq"]) { + deleteChain[m] = vi.fn().mockReturnValue(deleteChain); + } + (deleteChain.eq as ReturnType).mockResolvedValue(deleteResult); + let callCount = 0; + mockFrom.mockImplementation(() => { + callCount++; + return callCount === 1 ? ownerChain : deleteChain; + }); + return deleteChain; + } + + it("refuses with 409 when the gig has a hired application", async () => { + mockGetAuthContext.mockResolvedValue(authContext); + const deleteChain = ownerThenDelete(); + svcResults.applications = [{ count: 1, error: null }]; + + const res = await DELETE(makeRequest("DELETE"), routeParams); + const json = await res.json(); + + expect(res.status).toBe(409); + expect(json.error).toMatch(/hired/); + expect(deleteChain.delete).not.toHaveBeenCalled(); + const hiredQuery = svcCalls.find((c) => c.table === "applications")!; + expect(hiredQuery.chain.in).toHaveBeenCalledWith("status", [ + "accepted", + "in_progress", + "completed", + "paid", + ]); + expect(svcCalls.some((c) => c.table === "notifications")).toBe(false); + }); + + it("refuses with 409 when the gig has an unpaid sent invoice", async () => { + mockGetAuthContext.mockResolvedValue(authContext); + const deleteChain = ownerThenDelete(); + svcResults.applications = [{ count: 0, error: null }]; + svcResults.gig_invoices = [{ count: 2, error: null }]; + + const res = await DELETE(makeRequest("DELETE"), routeParams); + const json = await res.json(); + + expect(res.status).toBe(409); + expect(json.error).toMatch(/unpaid invoice/); + expect(deleteChain.delete).not.toHaveBeenCalled(); + const invoiceQuery = svcCalls.find((c) => c.table === "gig_invoices")!; + expect(invoiceQuery.chain.eq).toHaveBeenCalledWith("status", "sent"); + }); + + it("notifies each open applicant once in-app after deleting", async () => { + mockGetAuthContext.mockResolvedValue(authContext); + const deleteChain = ownerThenDelete(); + svcResults.applications = [ + { count: 0, error: null }, + { + data: [ + { id: "app-1", applicant_id: "applicant-1" }, + { id: "app-2", applicant_id: "applicant-2" }, + ], + error: null, + }, + ]; + svcResults.gig_invoices = [{ count: 0, error: null }]; + svcResults.notifications = [{ error: null }]; + + const res = await DELETE(makeRequest("DELETE"), routeParams); + + expect(res.status).toBe(200); + expect(deleteChain.delete).toHaveBeenCalled(); + const openQuery = svcCalls.filter((c) => c.table === "applications")[1]; + expect(openQuery.chain.in).toHaveBeenCalledWith("status", ["pending", "reviewing", "shortlisted"]); + expect(openQuery.chain.is).toHaveBeenCalledWith("metadata->>held", null); + const notify = svcCalls.find((c) => c.table === "notifications")!; + expect(notify.chain.insert).toHaveBeenCalledTimes(1); + const rows = notify.chain.insert.mock.calls[0][0] as Record[]; + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + user_id: "applicant-1", + type: "application_status", + data: { gig_id: "test-gig-id", application_id: "app-1", status: "gig_removed" }, + }); + expect(rows[0].body).toContain("Logo design"); + expect(rows[0].body).toContain("removed"); + }); + + it("does not notify anyone when the delete itself fails", async () => { + mockGetAuthContext.mockResolvedValue(authContext); + ownerThenDelete({ error: { message: "boom" } }); + svcResults.applications = [ + { count: 0, error: null }, + { data: [{ id: "app-1", applicant_id: "applicant-1" }], error: null }, + ]; + svcResults.gig_invoices = [{ count: 0, error: null }]; + + const res = await DELETE(makeRequest("DELETE"), routeParams); + + expect(res.status).toBe(400); + expect(svcCalls.some((c) => c.table === "notifications")).toBe(false); + }); }); // ════════════════════════════════════════════════════════════════════ diff --git a/src/app/api/gigs/[id]/route.ts b/src/app/api/gigs/[id]/route.ts index dd698dfc..37b0072c 100644 --- a/src/app/api/gigs/[id]/route.ts +++ b/src/app/api/gigs/[id]/route.ts @@ -1,7 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; import { gigSchema } from "@/lib/validations"; -import { getAuthContext } from "@/lib/auth/get-user"; +import { getAuthContext, createServiceClient } from "@/lib/auth/get-user"; +import { + HIRED_APPLICATION_STATUSES, + OPEN_APPLICATION_STATUSES, +} from "@/lib/application-status"; +import { HELD_COLUMN } from "@/lib/limits"; import { usersAreBlocked } from "@/lib/blocks"; import { checkForHireAdActivation, computeExpiresAt, limitResponse } from "@/lib/limits"; @@ -171,7 +176,7 @@ export async function DELETE( // Check ownership const { data: existingGig } = await supabase .from("gigs") - .select("poster_id") + .select("poster_id, title") .eq("id", id) .single(); @@ -183,12 +188,85 @@ export async function DELETE( return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } + // The service client sees every application and invoice on the gig + // regardless of RLS, and is the only writer allowed to insert + // notifications for other users. + const svc = createServiceClient(); + + // A gig with a hire or an unpaid invoice is someone's live work: deleting + // it would cascade away the record they need to get paid. + const { count: hiredCount, error: hiredError } = await svc + .from("applications") + .select("id", { count: "exact", head: true }) + .eq("gig_id", id) + .in("status", [...HIRED_APPLICATION_STATUSES]); + + if (hiredError) { + return NextResponse.json({ error: hiredError.message }, { status: 500 }); + } + if ((hiredCount ?? 0) > 0) { + return NextResponse.json( + { + error: + "This gig has a hired worker, so it can't be deleted. Close it or mark it filled instead.", + }, + { status: 409 } + ); + } + + // gig_invoices is not in the hand-maintained Database types. + const { count: unpaidInvoices, error: invoiceError } = await (svc as any) + .from("gig_invoices") + .select("id", { count: "exact", head: true }) + .eq("gig_id", id) + .eq("status", "sent"); + + if (invoiceError) { + return NextResponse.json({ error: invoiceError.message }, { status: 500 }); + } + if ((unpaidInvoices ?? 0) > 0) { + return NextResponse.json( + { + error: + "This gig has an unpaid invoice, so it can't be deleted. Pay or reject the invoice first.", + }, + { status: 409 } + ); + } + + // Everyone still waiting on this gig gets one in-app notice. Read the + // open applications now: they are deleted along with the gig. + const { data: openApplications } = await svc + .from("applications") + .select("id, applicant_id") + .eq("gig_id", id) + .in("status", [...OPEN_APPLICATION_STATUSES]) + .is(HELD_COLUMN, null); + + const gigTitle = existingGig.title || "A gig you applied to"; + const notifications = (openApplications ?? []).map((app) => ({ + user_id: app.applicant_id, + type: "application_status" as const, + title: "Gig removed", + body: `"${gigTitle}" was removed by the poster, so your application is closed.`, + data: { gig_id: id, application_id: app.id, status: "gig_removed" }, + })); + const { error } = await supabase.from("gigs").delete().eq("id", id); if (error) { return NextResponse.json({ error: error.message }, { status: 400 }); } + // Sent only once the delete has succeeded, so a failed delete never tells + // anyone their application is closed. + if (notifications.length > 0) { + const { error: notifyError } = await svc.from("notifications").insert(notifications); + if (notifyError) { + console.error("[gig-delete] failed to notify applicants:", notifyError); + } + } + return NextResponse.json({ message: "Gig deleted successfully" }); } catch { return NextResponse.json( diff --git a/src/app/api/gigs/[id]/status/route.resolve.test.ts b/src/app/api/gigs/[id]/status/route.resolve.test.ts new file mode 100644 index 00000000..05786f6a --- /dev/null +++ b/src/app/api/gigs/[id]/status/route.resolve.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { PATCH } from "./route"; + +const mockFrom = vi.fn(); +const supabaseClient = { from: mockFrom, rpc: vi.fn().mockResolvedValue({ error: null }) }; + +const getUserById = vi.fn().mockResolvedValue({ data: { user: { email: "poster@example.test" } } }); + +vi.mock("@/lib/auth/get-user", () => ({ + getAuthContext: vi.fn(), + createServiceClient: vi.fn(() => ({ auth: { admin: { getUserById } } })), +})); + +vi.mock("@/lib/webhooks/dispatch", () => ({ dispatchWebhookAsync: vi.fn() })); + +vi.mock("@/lib/email", () => ({ + sendEmail: vi.fn().mockResolvedValue({ success: true }), + gigFilledEmail: vi.fn(() => ({ subject: "s", html: "", text: "t" })), +})); + +vi.mock("@/lib/notification-settings", () => ({ + isEmailNotificationEnabled: vi.fn().mockResolvedValue(true), +})); + +vi.mock("@/lib/application-resolution", () => ({ + rejectOpenApplications: vi.fn(), +})); + +vi.mock("@/lib/application-emails", () => ({ + emailApplicantsAboutStatusInBackground: vi.fn(), +})); + +import { getAuthContext } from "@/lib/auth/get-user"; +import { sendEmail } from "@/lib/email"; +import { isEmailNotificationEnabled } from "@/lib/notification-settings"; +import { rejectOpenApplications } from "@/lib/application-resolution"; +import { emailApplicantsAboutStatusInBackground } from "@/lib/application-emails"; + +const mockGetAuthContext = vi.mocked(getAuthContext); +const mockReject = vi.mocked(rejectOpenApplications); +const mockEmailApplicants = vi.mocked(emailApplicantsAboutStatusInBackground); + +const routeParams = { params: Promise.resolve({ id: "gig-1" }) }; +const request = (status: string) => + new NextRequest("http://localhost/api/gigs/gig-1/status", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + +function chain(result: unknown) { + const c: Record> & { then?: unknown } = {}; + for (const m of ["select", "update", "eq", "in"]) c[m] = vi.fn(() => c); + c.single = vi.fn().mockResolvedValue(result); + c.then = (resolve: (v: unknown) => unknown) => Promise.resolve(result).then(resolve); + return c; +} + +function setupGig(oldStatus: string) { + mockGetAuthContext.mockResolvedValue({ user: { id: "poster-1" }, supabase: supabaseClient } as never); + let gigCalls = 0; + mockFrom.mockImplementation((table: string) => { + if (table === "gigs") { + gigCalls++; + return gigCalls === 1 + ? chain({ + data: { + poster_id: "poster-1", + status: oldStatus, + created_at: "2026-10-01T00:00:00Z", + title: "Video edit", + poster: { full_name: "Pat", username: "pat" }, + }, + error: null, + }) + : chain({ data: { id: "gig-1", status: "updated" }, error: null }); + } + // applications hired count for the filled email + return chain({ count: 1, error: null }); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockReject.mockResolvedValue([ + { id: "app-1", applicant_id: "worker-1", previous_status: "pending" }, + { id: "app-2", applicant_id: "worker-2", previous_status: "shortlisted" }, + ]); +}); + +describe("PATCH /api/gigs/[id]/status: resolving open applications", () => { + it("rejects open applications with reason gig_closed when the gig is closed", async () => { + setupGig("active"); + const res = await PATCH(request("closed"), routeParams); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(mockReject).toHaveBeenCalledWith(supabaseClient, "gig-1", "gig_closed"); + expect(json.resolved_applications).toBe(2); + expect(mockEmailApplicants).toHaveBeenCalledWith([ + { + applicationId: "app-1", + applicantId: "worker-1", + gigId: "gig-1", + gigTitle: "Video edit", + posterName: "Pat", + status: "rejected", + }, + { + applicationId: "app-2", + applicantId: "worker-2", + gigId: "gig-1", + gigTitle: "Video edit", + posterName: "Pat", + status: "rejected", + }, + ]); + }); + + it("uses reason gig_filled when the gig is filled", async () => { + setupGig("active"); + const res = await PATCH(request("filled"), routeParams); + expect(res.status).toBe(200); + expect(mockReject).toHaveBeenCalledWith(supabaseClient, "gig-1", "gig_filled"); + }); + + it.each(["paused", "draft", "active"])("leaves applications alone when the gig becomes %s", async (status) => { + setupGig(status === "active" ? "paused" : "active"); + const res = await PATCH(request(status), routeParams); + expect(res.status).toBe(200); + expect(mockReject).not.toHaveBeenCalled(); + expect(mockEmailApplicants).not.toHaveBeenCalled(); + }); + + it("does nothing when the gig was already closed", async () => { + setupGig("closed"); + await PATCH(request("closed"), routeParams); + expect(mockReject).not.toHaveBeenCalled(); + }); + + it("still succeeds when resolving applications throws", async () => { + setupGig("active"); + mockReject.mockRejectedValue(new Error("db down")); + const res = await PATCH(request("closed"), routeParams); + expect(res.status).toBe(200); + expect(mockEmailApplicants).not.toHaveBeenCalled(); + }); +}); + +describe("PATCH /api/gigs/[id]/status: gig filled email", () => { + it("sends the filled email with an unsubscribe for email_gig_updates", async () => { + setupGig("active"); + await PATCH(request("filled"), routeParams); + expect(isEmailNotificationEnabled).toHaveBeenCalledWith(expect.anything(), "poster-1", "email_gig_updates"); + expect(sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: "poster@example.test", + unsubscribe: { userId: "poster-1", setting: "email_gig_updates" }, + }) + ); + }); + + it("skips the filled email when email_gig_updates is off", async () => { + setupGig("active"); + vi.mocked(isEmailNotificationEnabled).mockResolvedValueOnce(false); + await PATCH(request("filled"), routeParams); + expect(sendEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/gigs/[id]/status/route.ts b/src/app/api/gigs/[id]/status/route.ts index 7d7b2e80..8f6f34df 100644 --- a/src/app/api/gigs/[id]/status/route.ts +++ b/src/app/api/gigs/[id]/status/route.ts @@ -1,10 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; +import { isEmailNotificationEnabled } from "@/lib/notification-settings"; import { getAuthContext, createServiceClient } from "@/lib/auth/get-user"; import { z } from "zod"; import { dispatchWebhookAsync } from "@/lib/webhooks/dispatch"; import { sendEmail, gigFilledEmail } from "@/lib/email"; import { HIRED_APPLICATION_STATUSES } from "@/lib/application-status"; import { checkForHireAdActivation, computeExpiresAt, limitResponse } from "@/lib/limits"; +import { rejectOpenApplications } from "@/lib/application-resolution"; +import { emailApplicantsAboutStatusInBackground } from "@/lib/application-emails"; const statusUpdateSchema = z.object({ status: z.enum(["draft", "active", "paused", "closed", "filled"]), @@ -133,6 +136,38 @@ export async function PATCH( new_status: newStatus, }); + // Closing or filling a gig resolves its open applications: everything + // still pending/reviewing/shortlisted becomes rejected with a reason, so + // applicants are not left waiting on a gig that is gone. The DB trigger + // notifies each one in-app; the email below is gated per applicant. + let resolvedApplications = 0; + if ( + (newStatus === "closed" || newStatus === "filled") && + oldStatus !== newStatus + ) { + try { + const resolved = await rejectOpenApplications( + supabase, + id, + newStatus === "filled" ? "gig_filled" : "gig_closed" + ); + resolvedApplications = resolved.length; + const poster = existingGig.poster as { full_name: string | null; username: string | null } | null; + emailApplicantsAboutStatusInBackground( + resolved.map((app) => ({ + applicationId: app.id, + applicantId: app.applicant_id, + gigId: id, + gigTitle: existingGig.title || "a gig", + posterName: poster?.full_name || poster?.username || "The client", + status: "rejected", + })) + ); + } catch (err) { + console.error("[gig-status] failed to resolve open applications:", err); + } + } + // Send email when gig is filled if (newStatus === "filled" && oldStatus !== "filled") { // Get count of accepted applications @@ -149,7 +184,10 @@ export async function PATCH( const poster = existingGig.poster as { full_name: string | null; username: string | null } | null; - if (posterEmail) { + if ( + posterEmail && + (await isEmailNotificationEnabled(adminClient, user.id, "email_gig_updates")) + ) { void sendEmail({ to: posterEmail, ...gigFilledEmail({ @@ -158,11 +196,12 @@ export async function PATCH( gigId: id, hiredCount: hiredCount || 0, }), + unsubscribe: { userId: user.id, setting: "email_gig_updates" }, }); } } - return NextResponse.json({ gig }); + return NextResponse.json({ gig, resolved_applications: resolvedApplications }); } catch { return NextResponse.json( { error: "An unexpected error occurred" }, diff --git a/src/app/api/messages/broadcast/route.test.ts b/src/app/api/messages/broadcast/route.test.ts index bb968a48..f6e46527 100644 --- a/src/app/api/messages/broadcast/route.test.ts +++ b/src/app/api/messages/broadcast/route.test.ts @@ -357,6 +357,11 @@ describe("POST /api/messages/broadcast", () => { expect(dispatchWebhookAsync).toHaveBeenCalledTimes(2); expect(sendEmail).toHaveBeenCalledTimes(2); expect(body.emailed).toBe(2); + expect(sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + unsubscribe: { userId: APPLICANT_1, setting: "email_new_message" }, + }) + ); }); it("skips email for recipients who opted out of message emails", async () => { diff --git a/src/app/api/messages/broadcast/route.ts b/src/app/api/messages/broadcast/route.ts index e22a46f1..77473dbf 100644 --- a/src/app/api/messages/broadcast/route.ts +++ b/src/app/api/messages/broadcast/route.ts @@ -318,7 +318,11 @@ async function sendBroadcastEmails({ gigTitle: null, }); - sendEmail({ to, ...emailContent }).catch((err) => + sendEmail({ + to, + ...emailContent, + unsubscribe: { userId: recipientId, setting: "email_new_message" }, + }).catch((err) => console.error("Failed to send broadcast message email:", err) ); sent++; diff --git a/src/app/api/messages/send/route.ts b/src/app/api/messages/send/route.ts index 38ca47ee..47019c6e 100644 --- a/src/app/api/messages/send/route.ts +++ b/src/app/api/messages/send/route.ts @@ -217,6 +217,7 @@ export async function POST(request: NextRequest) { sendEmail({ to: recipientEmail, ...emailContent, + unsubscribe: { userId: recipientProfile.id, setting: "email_new_message" }, }).catch((err) => console.error("Failed to send message notification email:", err) ); diff --git a/src/app/api/posts/[id]/comments/route.test.ts b/src/app/api/posts/[id]/comments/route.test.ts index d7761ddc..fd9cd40f 100644 --- a/src/app/api/posts/[id]/comments/route.test.ts +++ b/src/app/api/posts/[id]/comments/route.test.ts @@ -51,6 +51,11 @@ vi.mock("@/lib/email", () => ({ mentionInCommentEmail: (...args: unknown[]) => mockMentionInCommentEmail(...args), })); +const mockEmailEnabled = vi.fn().mockResolvedValue(true); +vi.mock("@/lib/notification-settings", () => ({ + isEmailNotificationEnabled: (...args: unknown[]) => mockEmailEnabled(...args), +})); + import { GET, POST } from "./route"; import { getAuthContext } from "@/lib/auth/get-user"; @@ -120,6 +125,7 @@ function setupSeq(...chains: ReturnType[]) { beforeEach(() => { vi.clearAllMocks(); + mockEmailEnabled.mockResolvedValue(true); }); // ════════════════════════════════════════════════════════════════════ @@ -280,8 +286,32 @@ describe("POST /api/posts/[id]/comments — email notifications", () => { expect.objectContaining({ replyPreview: "Reply" }) ); expect(mockSendEmail).toHaveBeenCalledWith( - expect.objectContaining({ to: "parent@test.com" }) + expect.objectContaining({ + to: "parent@test.com", + unsubscribe: { userId: USER_PARENT, setting: "email_new_comment" }, + }) ); + expect(mockEmailEnabled).toHaveBeenCalledWith(expect.anything(), USER_PARENT, "email_new_comment"); + }); + + it("sends no comment emails to people who turned email_new_comment off", async () => { + mockAuth(USER_3); + mockEmailEnabled.mockResolvedValue(false); + mockGetUserById.mockResolvedValue({ data: { user: { email: "someone@test.com" } } }); + + setupSeq( + ch({ data: { is_spam: false }, error: null }), + ch({ data: { id: POST_ID, author_id: AUTHOR_1 }, error: null }), + ch({ data: { id: COMMENT_1, post_id: POST_ID, parent_id: null, depth: 0, author_id: USER_PARENT, content: "Original" }, error: null }), + ch({ data: { id: COMMENT_2, post_id: POST_ID, author_id: USER_3, parent_id: COMMENT_1, content: "Reply", depth: 1, author: { id: USER_3, username: "u3" } }, error: null }), + ch({ data: { username: "u3", full_name: "User 3" }, error: null }), + ch({ data: { content: "Post body" }, error: null }), + ); + + await POST(makePostRequest(POST_ID, { content: "Reply", parent_id: COMMENT_1 }), makeParams(POST_ID)); + await new Promise((r) => setTimeout(r, 100)); + + expect(mockSendEmail).not.toHaveBeenCalled(); }); it("does not double-notify when post author IS parent comment author", async () => { @@ -387,6 +417,11 @@ describe("POST /api/posts/[id]/comments — @mentions", () => { expect(mockMentionInCommentEmail).toHaveBeenCalledWith( expect.objectContaining({ mentionerName: "User 2" }) ); + expect(mockSendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + unsubscribe: { userId: MENTIONED_USER, setting: "email_mention" }, + }) + ); }); it("does not notify self-mentioned user", async () => { diff --git a/src/app/api/posts/[id]/comments/route.ts b/src/app/api/posts/[id]/comments/route.ts index d8425356..a29f60f6 100644 --- a/src/app/api/posts/[id]/comments/route.ts +++ b/src/app/api/posts/[id]/comments/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { isEmailNotificationEnabled } from "@/lib/notification-settings"; import { createClient } from "@/lib/supabase/server"; import { getAuthContext, createServiceClient } from "@/lib/auth/get-user"; import { postCommentSchema } from "@/lib/validations"; @@ -324,7 +325,10 @@ export async function POST( ); const parentAuthorEmail = parentAuthorAuth?.user?.email; - if (parentAuthorEmail) { + if ( + parentAuthorEmail && + (await isEmailNotificationEnabled(adminClient, parentComment.author_id, "email_new_comment")) + ) { const { data: parentAuthorProfile } = await supabase .from("profiles") .select("full_name, username") @@ -344,6 +348,7 @@ export async function POST( subject: emailContent.subject, html: emailContent.html, text: emailContent.text, + unsubscribe: { userId: parentComment.author_id, setting: "email_new_comment" }, }).catch(() => {}); } } @@ -371,7 +376,10 @@ export async function POST( ); const postAuthorEmail = postAuthorAuth?.user?.email; - if (postAuthorEmail) { + if ( + postAuthorEmail && + (await isEmailNotificationEnabled(adminClient, post.author_id, "email_new_comment")) + ) { const { data: postAuthorProfile } = await supabase .from("profiles") .select("full_name, username") @@ -391,6 +399,7 @@ export async function POST( subject: emailContent.subject, html: emailContent.html, text: emailContent.text, + unsubscribe: { userId: post.author_id, setting: "email_new_comment" }, }).catch(() => {}); } } @@ -431,7 +440,10 @@ export async function POST( ); const mentionedEmail = mentionedAuth?.user?.email; - if (mentionedEmail) { + if ( + mentionedEmail && + (await isEmailNotificationEnabled(adminClient, mentionedUser.id, "email_mention")) + ) { const emailContent = mentionInCommentEmail({ recipientName: mentionedUser.full_name || mentionedUser.username || "there", mentionerName: commenterName, @@ -444,6 +456,7 @@ export async function POST( subject: emailContent.subject, html: emailContent.html, text: emailContent.text, + unsubscribe: { userId: mentionedUser.id, setting: "email_mention" }, }).catch(() => {}); } } diff --git a/src/app/api/posts/[id]/upvote/route.test.ts b/src/app/api/posts/[id]/upvote/route.test.ts new file mode 100644 index 00000000..5ae246bd --- /dev/null +++ b/src/app/api/posts/[id]/upvote/route.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + getAuthContext: vi.fn(), + sendEmail: vi.fn(), + emailEnabled: vi.fn(), +})); + +const svc = { + auth: { + admin: { + getUserById: async (id: string) => ({ data: { user: { id, email: `${id}@example.test` } } }), + }, + }, +}; + +vi.mock("@/lib/auth/get-user", () => ({ + getAuthContext: mocks.getAuthContext, + createServiceClient: () => svc, +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: () => ({ allowed: true }), + rateLimitExceeded: vi.fn(), + getRateLimitIdentifier: () => "t", +})); +vi.mock("@/lib/email", () => ({ + sendEmail: mocks.sendEmail, + upvoteMilestoneEmail: () => ({ subject: "s", html: "", text: "t" }), +})); +vi.mock("@/lib/notification-settings", () => ({ + isEmailNotificationEnabled: mocks.emailEnabled, +})); +vi.mock("@/lib/reputation-hooks", () => ({ getUserDid: async () => null, onUpvoted: vi.fn() })); +vi.mock("@/lib/activity", () => ({ logActivity: async () => {} })); + +import { POST } from "./route"; + +const AUTHOR = "author-1"; +const VOTER = "voter-1"; + +/** posts is read twice: before (4 upvotes) and after (5: crosses the 5 milestone). */ +function makeSupabase() { + let postReads = 0; + return { + from(table: string) { + const chain: Record = {}; + for (const m of ["select", "eq", "insert", "update", "delete"]) chain[m] = () => chain; + chain.single = () => { + if (table === "posts") { + postReads++; + return Promise.resolve( + postReads === 1 + ? { data: { id: "p1", upvotes: 4, content: "hello", author_id: AUTHOR, author: { full_name: "Au", username: "au" } }, error: null } + : { data: { upvotes: 5, downvotes: 0, score: 5 }, error: null } + ); + } + return Promise.resolve({ data: null, error: null }); + }; + chain.then = (resolve: (v: unknown) => unknown) => resolve({ data: null, error: null }); + return chain; + }, + }; +} + +const request = () => new NextRequest("http://localhost/api/posts/p1/upvote", { method: "POST" }); +const params = { params: Promise.resolve({ id: "p1" }) }; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.sendEmail.mockResolvedValue({ success: true }); + mocks.emailEnabled.mockResolvedValue(true); + mocks.getAuthContext.mockResolvedValue({ user: { id: VOTER }, supabase: makeSupabase() }); +}); + +describe("POST /api/posts/[id]/upvote milestone email", () => { + it("emails the author with an email_upvote_milestone unsubscribe", async () => { + const res = await POST(request(), params); + expect(res.status).toBe(200); + expect(mocks.emailEnabled).toHaveBeenCalledWith(svc, AUTHOR, "email_upvote_milestone"); + expect(mocks.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: `${AUTHOR}@example.test`, + unsubscribe: { userId: AUTHOR, setting: "email_upvote_milestone" }, + }) + ); + }); + + it("sends nothing when email_upvote_milestone is off", async () => { + mocks.emailEnabled.mockResolvedValue(false); + const res = await POST(request(), params); + expect(res.status).toBe(200); + expect(mocks.sendEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/posts/[id]/upvote/route.ts b/src/app/api/posts/[id]/upvote/route.ts index 89146913..71450dea 100644 --- a/src/app/api/posts/[id]/upvote/route.ts +++ b/src/app/api/posts/[id]/upvote/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { isEmailNotificationEnabled } from "@/lib/notification-settings"; import { getAuthContext, createServiceClient } from "@/lib/auth/get-user"; import { checkRateLimit, rateLimitExceeded, getRateLimitIdentifier } from "@/lib/rate-limit"; import { sendEmail, upvoteMilestoneEmail } from "@/lib/email"; @@ -120,7 +121,10 @@ export async function POST( const { data: authorAuth } = await adminClient.auth.admin.getUserById(post.author_id); const authorEmail = authorAuth?.user?.email; - if (authorEmail) { + if ( + authorEmail && + (await isEmailNotificationEnabled(adminClient, post.author_id, "email_upvote_milestone")) + ) { void sendEmail({ to: authorEmail, ...upvoteMilestoneEmail({ @@ -129,6 +133,7 @@ export async function POST( postId, milestone: crossedMilestone, }), + unsubscribe: { userId: post.author_id, setting: "email_upvote_milestone" }, }); } } diff --git a/src/app/api/reviews/route.email.test.ts b/src/app/api/reviews/route.email.test.ts new file mode 100644 index 00000000..12af864b --- /dev/null +++ b/src/app/api/reviews/route.email.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + getAuthContext: vi.fn(), + sendEmail: vi.fn(), + emailEnabled: vi.fn(), +})); + +const svc = { + auth: { + admin: { + getUserById: async (id: string) => ({ data: { user: { id, email: `${id}@example.test` } } }), + }, + }, +}; + +vi.mock("@/lib/auth/get-user", () => ({ + getAuthContext: mocks.getAuthContext, + createServiceClient: () => svc, +})); +vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn() })); +vi.mock("@/lib/email", () => ({ + sendEmail: mocks.sendEmail, + reviewReceivedEmail: () => ({ subject: "s", html: "", text: "t" }), +})); +vi.mock("@/lib/notification-settings", () => ({ + isEmailNotificationEnabled: mocks.emailEnabled, +})); +vi.mock("@/lib/blocks", () => ({ + usersAreBlocked: async () => false, + getBlockedUserIds: async () => [], + excludeBlocked: (q: unknown) => q, +})); +vi.mock("@/lib/webhooks/dispatch", () => ({ dispatchWebhookAsync: vi.fn() })); +vi.mock("@/lib/reputation-hooks", () => ({ getUserDid: async () => null, onReviewCreated: vi.fn() })); +vi.mock("@/lib/activity", () => ({ logActivity: async () => {} })); + +import { POST } from "./route"; + +const POSTER = "00000000-0000-4000-a000-000000000001"; +const WORKER = "00000000-0000-4000-a000-000000000002"; +const GIG = "00000000-0000-4000-a000-0000000000ff"; + +function makeSupabase() { + let reviewSingles = 0; + return { + from(table: string) { + const chain: Record = {}; + for (const m of ["select", "eq", "in", "limit", "insert"]) chain[m] = () => chain; + chain.maybeSingle = () => Promise.resolve({ data: { id: "app-1" }, error: null }); + chain.single = () => { + if (table === "gigs") + return Promise.resolve({ data: { id: GIG, title: "Logo", poster_id: POSTER, status: "filled" }, error: null }); + if (table === "reviews") { + reviewSingles++; + return Promise.resolve( + reviewSingles === 1 + ? { data: null, error: null } + : { + data: { + id: "rev-1", + reviewer: { id: POSTER, username: "pat", full_name: "Pat" }, + reviewee: { id: WORKER, username: "wren", full_name: "Wren" }, + }, + error: null, + } + ); + } + return Promise.resolve({ data: null, error: null }); + }; + return chain; + }, + }; +} + +const request = () => + new NextRequest("http://localhost/api/reviews", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ gig_id: GIG, reviewee_id: WORKER, rating: 5, comment: "Great" }), + }); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.sendEmail.mockResolvedValue({ success: true }); + mocks.emailEnabled.mockResolvedValue(true); + mocks.getAuthContext.mockResolvedValue({ user: { id: POSTER }, supabase: makeSupabase() }); +}); + +describe("POST /api/reviews email", () => { + it("emails the reviewee with an email_review_received unsubscribe", async () => { + const res = await POST(request()); + expect(res.status).toBe(201); + expect(mocks.emailEnabled).toHaveBeenCalledWith(svc, WORKER, "email_review_received"); + expect(mocks.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: `${WORKER}@example.test`, + unsubscribe: { userId: WORKER, setting: "email_review_received" }, + }) + ); + }); + + it("sends nothing when email_review_received is off", async () => { + mocks.emailEnabled.mockResolvedValue(false); + const res = await POST(request()); + expect(res.status).toBe(201); + expect(mocks.sendEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/reviews/route.ts b/src/app/api/reviews/route.ts index de83336f..90408b45 100644 --- a/src/app/api/reviews/route.ts +++ b/src/app/api/reviews/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { isEmailNotificationEnabled } from "@/lib/notification-settings"; import { createClient } from "@/lib/supabase/server"; import { getAuthContext, createServiceClient } from "@/lib/auth/get-user"; import { z } from "zod"; @@ -323,7 +324,11 @@ export async function POST(request: NextRequest) { const { data: revieweeAuth } = await adminClient.auth.admin.getUserById(reviewee_id); const revieweeEmail = revieweeAuth?.user?.email; - if (revieweeEmail && gig) { + if ( + revieweeEmail && + gig && + (await isEmailNotificationEnabled(adminClient, reviewee_id, "email_review_received")) + ) { void sendEmail({ to: revieweeEmail, ...reviewReceivedEmail({ @@ -334,6 +339,7 @@ export async function POST(request: NextRequest) { rating, comment, }), + unsubscribe: { userId: reviewee_id, setting: "email_review_received" }, }); } diff --git a/src/app/api/users/[username]/endorse/route.test.ts b/src/app/api/users/[username]/endorse/route.test.ts index a85f39be..322c1228 100644 --- a/src/app/api/users/[username]/endorse/route.test.ts +++ b/src/app/api/users/[username]/endorse/route.test.ts @@ -41,11 +41,17 @@ vi.mock("@/lib/reputation-hooks", () => ({ onEndorsementGiven: vi.fn(), })); +const mockEmailEnabled = vi.fn().mockResolvedValue(true); +vi.mock("@/lib/notification-settings", () => ({ + isEmailNotificationEnabled: (...args: unknown[]) => mockEmailEnabled(...args), +})); + vi.mock("@/lib/activity", () => ({ logActivity: vi.fn(), })); import { getAuthContext } from "@/lib/auth/get-user"; +import { sendEmail } from "@/lib/email"; import type { AuthContext } from "@/lib/auth/get-user"; const mockGetAuthContext = vi.mocked(getAuthContext); @@ -261,6 +267,54 @@ describe("POST /api/users/:username/endorse", () => { expect(json.data.skill).toBe("React"); }); + function setupApiKeyEndorsement() { + mockGetAuthContext.mockResolvedValue({ + ...authContext, + user: { ...(authContext as unknown as { user: Record }).user, authMethod: "api_key" }, + } as unknown as AuthContext); + supabaseClient.auth.admin.getUserById.mockResolvedValue({ + data: { user: { email: "endorsed@example.test" } }, + }); + let callCount = 0; + mockFrom.mockImplementation(() => { + callCount++; + if (callCount === 1) + return chainResult({ + data: { id: endorsedUserId, username: "testuser", full_name: "Test User", skills: ["React"] }, + error: null, + }); + if (callCount === 2) + return chainResult({ + data: { id: "end-1", skill: "React", endorser: { id: userId } }, + error: null, + }); + return chainResult({ data: { full_name: "Endorser", username: "endorser", did: null }, error: null }); + }); + } + + it("emails the endorsed user with an email_endorsement_received unsubscribe", async () => { + setupApiKeyEndorsement(); + const res = await POST(makeRequest("POST", { skill: "React" }), routeParams); + expect(res.status).toBe(201); + expect(mockEmailEnabled).toHaveBeenCalledWith(expect.anything(), endorsedUserId, "email_endorsement_received"); + expect(sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: "endorsed@example.test", + unsubscribe: { userId: endorsedUserId, setting: "email_endorsement_received" }, + }) + ); + supabaseClient.auth.admin.getUserById.mockResolvedValue({ data: null }); + }); + + it("does not email when email_endorsement_received is off", async () => { + setupApiKeyEndorsement(); + mockEmailEnabled.mockResolvedValueOnce(false); + const res = await POST(makeRequest("POST", { skill: "React" }), routeParams); + expect(res.status).toBe(201); + expect(sendEmail).not.toHaveBeenCalled(); + supabaseClient.auth.admin.getUserById.mockResolvedValue({ data: null }); + }); + it("returns 409 when duplicate endorsement", async () => { mockGetAuthContext.mockResolvedValue(authContext); diff --git a/src/app/api/users/[username]/endorse/route.ts b/src/app/api/users/[username]/endorse/route.ts index 8419347e..70bb783c 100644 --- a/src/app/api/users/[username]/endorse/route.ts +++ b/src/app/api/users/[username]/endorse/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { isEmailNotificationEnabled } from "@/lib/notification-settings"; import { getAuthContext, createServiceClient } from "@/lib/auth/get-user"; import { createClient } from "@/lib/supabase/server"; import { endorseSchema } from "@/lib/validations"; @@ -181,7 +182,10 @@ export async function POST( const { data: authUser } = await supabase.auth.admin.getUserById( endorsedProfile.id ); - if (authUser?.user?.email) { + if ( + authUser?.user?.email && + (await isEmailNotificationEnabled(supabase, endorsedProfile.id, "email_endorsement_received")) + ) { const emailContent = endorsementReceivedEmail({ endorsedName, endorserName, @@ -192,6 +196,7 @@ export async function POST( await sendEmail({ to: authUser.user.email, ...emailContent, + unsubscribe: { userId: endorsedProfile.id, setting: "email_endorsement_received" }, }); } } diff --git a/src/app/api/users/[username]/follow/route.test.ts b/src/app/api/users/[username]/follow/route.test.ts index 0f9f3cc1..bf77aa3e 100644 --- a/src/app/api/users/[username]/follow/route.test.ts +++ b/src/app/api/users/[username]/follow/route.test.ts @@ -43,6 +43,11 @@ vi.mock("@/lib/email", () => ({ })), })); +const mockEmailEnabled = vi.fn().mockResolvedValue(true); +vi.mock("@/lib/notification-settings", () => ({ + isEmailNotificationEnabled: (...args: unknown[]) => mockEmailEnabled(...args), +})); + vi.mock("@/lib/reputation-hooks", () => ({ getUserDid: vi.fn().mockResolvedValue("did:test:123"), onFollowed: vi.fn(), @@ -54,6 +59,7 @@ vi.mock("@/lib/activity", () => ({ })); import { getAuthContext } from "@/lib/auth/get-user"; +import { sendEmail } from "@/lib/email"; import type { AuthContext } from "@/lib/auth/get-user"; const mockGetAuthContext = vi.mocked(getAuthContext); @@ -87,6 +93,7 @@ function chainResult(result: { data: unknown; error: unknown }) { beforeEach(() => { vi.clearAllMocks(); + mockEmailEnabled.mockResolvedValue(true); mockAdminGetUserById.mockResolvedValue({ data: { user: { email: "target@example.com" } }, }); @@ -196,6 +203,45 @@ describe("POST /api/users/[username]/follow", () => { ); }); + function setupSuccessfulFollow() { + mockGetAuthContext.mockResolvedValue(authContext); + const profileChain = chainResult({ + data: { id: "target-456", username: "testuser", full_name: "Test User", did: null }, + error: null, + }); + const insertChain = { insert: vi.fn().mockResolvedValue({ error: null }) }; + const followerChain = chainResult({ data: { username: "me", full_name: "Me" }, error: null }); + let callCount = 0; + mockFrom.mockImplementation(() => { + callCount++; + if (callCount === 1) return profileChain; + if (callCount === 2) return insertChain; + return followerChain; + }); + mockServiceFrom.mockReturnValue({ insert: vi.fn().mockResolvedValue({ error: null }) }); + } + + it("emails the followed user with an email_new_follower unsubscribe", async () => { + setupSuccessfulFollow(); + const res = await POST(makeRequest("POST"), routeParams); + expect(res.status).toBe(201); + expect(mockEmailEnabled).toHaveBeenCalledWith(mockServiceClient, "target-456", "email_new_follower"); + expect(sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: "target@example.com", + unsubscribe: { userId: "target-456", setting: "email_new_follower" }, + }) + ); + }); + + it("does not email a user who turned email_new_follower off", async () => { + setupSuccessfulFollow(); + mockEmailEnabled.mockResolvedValue(false); + const res = await POST(makeRequest("POST"), routeParams); + expect(res.status).toBe(201); + expect(sendEmail).not.toHaveBeenCalled(); + }); + it("does not log activity when follow fails", async () => { mockGetAuthContext.mockResolvedValue(authContext); diff --git a/src/app/api/users/[username]/follow/route.ts b/src/app/api/users/[username]/follow/route.ts index 7e0cc91e..8d500374 100644 --- a/src/app/api/users/[username]/follow/route.ts +++ b/src/app/api/users/[username]/follow/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { isEmailNotificationEnabled } from "@/lib/notification-settings"; import { getAuthContext, createServiceClient } from "@/lib/auth/get-user"; import { sendEmail, newFollowerEmail } from "@/lib/email"; import { getUserDid, onFollowed } from "@/lib/reputation-hooks"; @@ -104,7 +105,10 @@ export async function POST( ); const targetEmail = targetAuth?.user?.email; - if (targetEmail) { + if ( + targetEmail && + (await isEmailNotificationEnabled(adminClient, targetProfile.id, "email_new_follower")) + ) { const emailContent = newFollowerEmail({ recipientName: targetProfile.full_name || targetProfile.username, @@ -116,6 +120,7 @@ export async function POST( subject: emailContent.subject, html: emailContent.html, text: emailContent.text, + unsubscribe: { userId: targetProfile.id, setting: "email_new_follower" }, }).catch(() => {}); } diff --git a/src/app/dashboard/applications/page.tsx b/src/app/dashboard/applications/page.tsx index 2d5a2ed1..26ff857c 100644 --- a/src/app/dashboard/applications/page.tsx +++ b/src/app/dashboard/applications/page.tsx @@ -6,6 +6,7 @@ import { Badge } from "@/components/ui/badge"; import { ArrowLeft, ExternalLink, Clock, DollarSign, Calendar, FileText } from "lucide-react"; import { fmtUSD } from "@/lib/utils"; import { isHiredStatus } from "@/lib/application-status"; +import { WithdrawApplicationButton } from "@/components/applications/WithdrawApplicationButton"; export const metadata = { title: "My Applications | ugig.net", @@ -184,9 +185,12 @@ export default async function MyApplicationsPage() { -

- {statusDescriptions[app.status]} -

+
+

+ {statusDescriptions[app.status]} +

+ +
diff --git a/src/app/gigs/[id]/applications/page.tsx b/src/app/gigs/[id]/applications/page.tsx index c79814af..d7de5408 100644 --- a/src/app/gigs/[id]/applications/page.tsx +++ b/src/app/gigs/[id]/applications/page.tsx @@ -12,6 +12,7 @@ import { ApplicationActions } from "@/components/applications/ApplicationActions import { ExpandableApplicationCard } from "@/components/applications/ExpandableApplicationCard"; import { StartConversationButton } from "@/components/messages/StartConversationButton"; import { MessageAllApplicantsButton } from "@/components/applications/MessageAllApplicantsButton"; +import { BulkApplicationActions, BulkSelectCheckbox } from "@/components/applications/BulkApplicationActions"; import { MarkdownContent } from "@/components/ui/MarkdownContent"; import { EscrowPaymentButton } from "@/components/gigs/EscrowPaymentButton"; import { InvoiceButton } from "@/components/gigs/InvoiceButton"; @@ -203,156 +204,164 @@ export default async function ApplicationsPage({ params }: ApplicationsPageProps

Active Applications

-
- {activeApps.map((app) => { - const applicant = Array.isArray(app.applicant) - ? app.applicant[0] - : app.applicant; - - return ( -
- {/* Header: Applicant info + Status */} -
- - {applicant?.full_name -
-

- {applicant?.full_name || applicant?.username} - {applicant?.verified && ( - - )} - {applicant?.account_type === "agent" && ( - - )} - -

-

- @{applicant?.username} -

+ a.id)}> +
+ {activeApps.map((app) => { + const applicant = Array.isArray(app.applicant) + ? app.applicant[0] + : app.applicant; + + return ( +
+ {/* Header: Applicant info + Status */} +
+
+ + + {applicant?.full_name +
+

+ {applicant?.full_name || applicant?.username} + {applicant?.verified && ( + + )} + {applicant?.account_type === "agent" && ( + + )} + +

+

+ @{applicant?.username} +

+
+
- - - {app.status} - -
+ + {app.status} + +
- {/* Cover Letter */} -
-

Cover Letter

- -
+ {/* Cover Letter */} +
+

Cover Letter

+ +
- {/* Details Row */} -
- - - Applied {formatRelativeTime(app.created_at)} - - {app.proposed_rate && ( + {/* Details Row */} +
- - ${app.proposed_rate} proposed + + Applied {formatRelativeTime(app.created_at)} - )} - {app.proposed_timeline && ( - - Timeline: {app.proposed_timeline} - - )} -
+ {app.proposed_rate && ( + + + ${app.proposed_rate} proposed + + )} + {app.proposed_timeline && ( + + Timeline: {app.proposed_timeline} + + )} +
- {/* AI Tools */} - {app.ai_tools_to_use && app.ai_tools_to_use.length > 0 && ( -
-

AI Tools

-
- {app.ai_tools_to_use.map((tool: string) => ( - - {tool} - - ))} + {/* AI Tools */} + {app.ai_tools_to_use && app.ai_tools_to_use.length > 0 && ( +
+

AI Tools

+
+ {app.ai_tools_to_use.map((tool: string) => ( + + {tool} + + ))} +
-
- )} + )} - {/* Portfolio */} - {app.portfolio_items && app.portfolio_items.length > 0 && ( -
-

Portfolio

-
- {app.portfolio_items.map((url: string, i: number) => ( - - {new URL(url).hostname} - - - ))} + {/* Portfolio */} + {app.portfolio_items && app.portfolio_items.length > 0 && ( +
+

Portfolio

+
+ {app.portfolio_items.map((url: string, i: number) => ( + + {new URL(url).hostname} + + + ))} +
-
- )} + )} - {/* Applicant Skills */} - {applicant?.skills && applicant.skills.length > 0 && ( -
-

Applicant Skills

-
- {applicant.skills.slice(0, 8).map((skill: string) => ( - - {skill} - - ))} - {applicant.skills.length > 8 && ( - - +{applicant.skills.length - 8} more - - )} + {/* Applicant Skills */} + {applicant?.skills && applicant.skills.length > 0 && ( +
+

Applicant Skills

+
+ {applicant.skills.slice(0, 8).map((skill: string) => ( + + {skill} + + ))} + {applicant.skills.length > 8 && ( + + +{applicant.skills.length - 8} more + + )} +
-
- )} + )} - {/* Actions */} -
- - {applicant?.id && ( - + - )} + {applicant?.id && ( + + )} +
-
- ); - })} -
+ ); + })} +
+
)} diff --git a/src/components/applications/BulkApplicationActions.test.tsx b/src/components/applications/BulkApplicationActions.test.tsx new file mode 100644 index 00000000..a1b62910 --- /dev/null +++ b/src/components/applications/BulkApplicationActions.test.tsx @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; + +const refresh = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh }), +})); + +import { BulkApplicationActions, BulkSelectCheckbox, chunkIds } from "./BulkApplicationActions"; +import { WithdrawApplicationButton } from "./WithdrawApplicationButton"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as unknown as typeof fetch; + +beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ updated: 1 }) }); + vi.spyOn(window, "confirm").mockReturnValue(true); +}); + +function renderList(ids: string[]) { + return render( + + {ids.map((id) => ( +
+ +
+ ))} +
+ ); +} + +function bodies() { + return mockFetch.mock.calls.map((c) => JSON.parse((c[1] as RequestInit).body as string)); +} + +describe("chunkIds", () => { + it("splits into batches of at most 50", () => { + const ids = Array.from({ length: 120 }, (_, i) => `id-${i}`); + expect(chunkIds(ids).map((c) => c.length)).toEqual([50, 50, 20]); + }); +}); + +describe("BulkApplicationActions", () => { + it("rejects the selected applications through bulk-status", async () => { + renderList(["a", "b", "c"]); + fireEvent.click(screen.getByTestId("select-application-a")); + fireEvent.click(screen.getByTestId("select-application-c")); + expect(screen.getByText("2 selected")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Reject/ })); + + await waitFor(() => expect(refresh).toHaveBeenCalled()); + expect(window.confirm).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledWith( + "/api/applications/bulk-status", + expect.objectContaining({ method: "PUT" }) + ); + expect(bodies()).toEqual([{ application_ids: ["a", "c"], status: "rejected" }]); + }); + + it("shortlists without a confirmation prompt", async () => { + renderList(["a", "b"]); + fireEvent.click(screen.getByTestId("select-application-b")); + fireEvent.click(screen.getByRole("button", { name: /Shortlist/ })); + await waitFor(() => expect(refresh).toHaveBeenCalled()); + expect(window.confirm).not.toHaveBeenCalled(); + expect(bodies()).toEqual([{ application_ids: ["b"], status: "shortlisted" }]); + }); + + it("select all covers every application and batches past 50", async () => { + const ids = Array.from({ length: 60 }, (_, i) => `app-${i}`); + renderList(ids); + fireEvent.click(screen.getByLabelText("Select all applications")); + expect(screen.getByText("60 selected")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /Reject/ })); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)); + expect(bodies().map((b) => b.application_ids.length)).toEqual([50, 10]); + }); + + it("does nothing when the reject is not confirmed", async () => { + vi.mocked(window.confirm).mockReturnValue(false); + renderList(["a", "b"]); + fireEvent.click(screen.getByTestId("select-application-a")); + fireEvent.click(screen.getByRole("button", { name: /Reject/ })); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("disables the actions until something is selected", () => { + renderList(["a", "b"]); + expect(screen.getByRole("button", { name: /Reject/ })).toBeDisabled(); + expect(screen.getByRole("button", { name: /Shortlist/ })).toBeDisabled(); + }); + + it("shows the API error", async () => { + mockFetch.mockResolvedValue({ ok: false, json: async () => ({ error: "You can only update applications for your own gigs" }) }); + renderList(["a", "b"]); + fireEvent.click(screen.getByTestId("select-application-a")); + fireEvent.click(screen.getByRole("button", { name: /Shortlist/ })); + expect(await screen.findByText(/your own gigs/)).toBeInTheDocument(); + }); + + it("hides the toolbar for a single application", () => { + renderList(["only"]); + expect(screen.queryByTestId("bulk-application-actions")).not.toBeInTheDocument(); + }); +}); + +describe("WithdrawApplicationButton", () => { + it("withdraws via DELETE /api/applications/[id] after confirming", async () => { + render(); + fireEvent.click(screen.getByTestId("withdraw-application-app-9")); + await waitFor(() => expect(refresh).toHaveBeenCalled()); + expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining("Logo")); + expect(mockFetch).toHaveBeenCalledWith("/api/applications/app-9", { method: "DELETE" }); + }); + + it("does not withdraw when cancelled", () => { + vi.mocked(window.confirm).mockReturnValue(false); + render(); + fireEvent.click(screen.getByTestId("withdraw-application-app-9")); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("shows the API error", async () => { + mockFetch.mockResolvedValue({ ok: false, json: async () => ({ error: "Application not found" }) }); + render(); + fireEvent.click(screen.getByTestId("withdraw-application-app-9")); + expect(await screen.findByText("Application not found")).toBeInTheDocument(); + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/applications/BulkApplicationActions.tsx b/src/components/applications/BulkApplicationActions.tsx new file mode 100644 index 00000000..db274216 --- /dev/null +++ b/src/components/applications/BulkApplicationActions.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, Star, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +/** PUT /api/applications/bulk-status accepts at most this many ids per call. */ +export const BULK_STATUS_MAX = 50; + +type BulkStatus = "rejected" | "shortlisted"; + +interface BulkSelectionContextValue { + selected: Set; + toggle: (id: string) => void; +} + +const BulkSelectionContext = createContext(null); + +export function chunkIds(ids: string[], size = BULK_STATUS_MAX): string[][] { + const chunks: string[][] = []; + for (let i = 0; i < ids.length; i += size) chunks.push(ids.slice(i, i + size)); + return chunks; +} + +interface BulkApplicationActionsProps { + /** Ids of the applications that can be selected (the active ones). */ + applicationIds: string[]; + children: ReactNode; +} + +/** + * Multi-select for the poster's applications list: a toolbar with Select all, + * Shortlist and Reject, plus a checkbox per application (BulkSelectCheckbox). + * Uses PUT /api/applications/bulk-status in batches of 50. + */ +export function BulkApplicationActions({ applicationIds, children }: BulkApplicationActionsProps) { + const router = useRouter(); + const [selected, setSelected] = useState>(new Set()); + const [loading, setLoading] = useState(null); + const [error, setError] = useState(null); + + const toggle = useCallback((id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const selectable = useMemo(() => new Set(applicationIds), [applicationIds]); + const selectedIds = [...selected].filter((id) => selectable.has(id)); + const allSelected = applicationIds.length > 0 && selectedIds.length === applicationIds.length; + + const toggleAll = () => { + setSelected(allSelected ? new Set() : new Set(applicationIds)); + }; + + const apply = async (status: BulkStatus) => { + if (selectedIds.length === 0) return; + if ( + status === "rejected" && + !window.confirm( + `Reject ${selectedIds.length} application${selectedIds.length === 1 ? "" : "s"}? Each applicant will be notified.` + ) + ) { + return; + } + + setLoading(status); + setError(null); + try { + for (const ids of chunkIds(selectedIds)) { + const res = await fetch("/api/applications/bulk-status", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ application_ids: ids, status }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || "Failed to update applications"); + } + setSelected(new Set()); + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update applications"); + router.refresh(); + } finally { + setLoading(null); + } + }; + + return ( + + {applicationIds.length > 1 && ( +
+ +
+ + +
+ {error &&

{error}

} +
+ )} + {children} +
+ ); +} + +/** Per-application checkbox; renders nothing outside BulkApplicationActions. */ +export function BulkSelectCheckbox({ applicationId, label }: { applicationId: string; label?: string }) { + const ctx = useContext(BulkSelectionContext); + if (!ctx) return null; + return ( + ctx.toggle(applicationId)} + aria-label={label ? `Select ${label}` : "Select application"} + data-testid={`select-application-${applicationId}`} + className="h-4 w-4 mt-4 shrink-0 cursor-pointer" + /> + ); +} diff --git a/src/components/applications/WithdrawApplicationButton.tsx b/src/components/applications/WithdrawApplicationButton.tsx new file mode 100644 index 00000000..899e0b00 --- /dev/null +++ b/src/components/applications/WithdrawApplicationButton.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, Undo2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface WithdrawApplicationButtonProps { + applicationId: string; + gigTitle?: string; +} + +/** Applicant-side withdraw, via DELETE /api/applications/[id]. */ +export function WithdrawApplicationButton({ applicationId, gigTitle }: WithdrawApplicationButtonProps) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const withdraw = async () => { + const what = gigTitle ? `your application to "${gigTitle}"` : "this application"; + if (!window.confirm(`Withdraw ${what}? The poster will no longer see it as active.`)) return; + + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/applications/${applicationId}`, { method: "DELETE" }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || "Failed to withdraw application"); + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to withdraw application"); + } finally { + setLoading(false); + } + }; + + return ( +
+ + {error &&

{error}

} +
+ ); +} diff --git a/src/lib/application-emails.test.ts b/src/lib/application-emails.test.ts new file mode 100644 index 00000000..6e042720 --- /dev/null +++ b/src/lib/application-emails.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/email", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, sendEmail: vi.fn() }; +}); + +vi.mock("@/lib/supabase/service", () => ({ + createServiceClient: vi.fn(() => { + throw new Error("not used in unit tests"); + }), +})); + +import { sendEmail } from "@/lib/email"; +import { + emailApplicantsAboutStatus, + notifyPosterOfNewApplication, + runApplicationDigest, + statusEmailsApplicant, +} from "./application-emails"; + +const mockSend = vi.mocked(sendEmail); + +// ── Fake service client ────────────────────────────────────────────── + +type Call = { method: string; args: unknown[] }; +interface Query { + table: string; + calls: Call[]; +} +type Resolver = (q: Query) => unknown; + +function fakeSvc(resolve: Resolver, emails: Record = {}) { + const queries: Query[] = []; + const from = vi.fn((table: string) => { + const q: Query = { table, calls: [] }; + queries.push(q); + const chain: Record = {}; + for (const m of ["select", "update", "insert", "upsert", "eq", "neq", "in", "is", "gte", "lt", "order", "range", "single"]) { + chain[m] = (...args: unknown[]) => { + q.calls.push({ method: m, args }); + return chain; + }; + } + chain.then = (ok: (v: unknown) => unknown, err: (e: unknown) => unknown) => + Promise.resolve().then(() => resolve(q)).then(ok, err); + return chain; + }); + const getUserById = vi.fn(async (id: string) => ({ + data: { user: emails[id] ? { id, email: emails[id] } : null }, + })); + return { + client: { from, auth: { admin: { getUserById } } } as never, + queries, + getUserById, + }; +} + +const has = (q: Query, method: string, ...args: unknown[]) => + q.calls.some((c) => c.method === method && JSON.stringify(c.args) === JSON.stringify(args)); +const updates = (queries: Query[]) => + queries.filter((q) => q.table === "applications" && q.calls.some((c) => c.method === "update")); + +beforeEach(() => { + vi.clearAllMocks(); + mockSend.mockResolvedValue({ success: true, data: { id: "m" } } as never); +}); + +// ── Applicant status emails ─────────────────────────────────────────── + +describe("statusEmailsApplicant", () => { + it("covers accepted, rejected and shortlisted only", () => { + expect(["accepted", "rejected", "shortlisted"].every(statusEmailsApplicant)).toBe(true); + expect(["pending", "reviewing", "withdrawn", "paid", "completed"].some(statusEmailsApplicant)).toBe(false); + }); +}); + +describe("emailApplicantsAboutStatus", () => { + const base = { gigId: "gig-1", gigTitle: "Logo", posterName: "Pat" }; + + it("emails each eligible applicant with an unsubscribe for email_application_status", async () => { + const { client } = fakeSvc( + (q) => { + if (q.table === "notification_settings") return { data: [], error: null }; + if (q.table === "profiles") return { data: [{ id: "w1", full_name: "Wren", username: "wren" }], error: null }; + return { data: null, error: null }; + }, + { w1: "w1@example.test" } + ); + + const res = await emailApplicantsAboutStatus(client, [ + { ...base, applicationId: "a1", applicantId: "w1", status: "accepted" }, + ]); + + expect(res.sent).toBe(1); + expect(mockSend).toHaveBeenCalledTimes(1); + const arg = mockSend.mock.calls[0][0]; + expect(arg.to).toBe("w1@example.test"); + expect(arg.subject).toBe("Application Accepted! - Logo"); + expect(arg.html).toContain("Hi Wren"); + expect(arg.unsubscribe).toEqual({ userId: "w1", setting: "email_application_status" }); + }); + + it("skips applicants who turned email_application_status off", async () => { + const { client } = fakeSvc( + (q) => { + if (q.table === "notification_settings") + return { data: [{ user_id: "w1", email_application_status: false }], error: null }; + return { data: [], error: null }; + }, + { w1: "w1@example.test", w2: "w2@example.test" } + ); + + const res = await emailApplicantsAboutStatus(client, [ + { ...base, applicationId: "a1", applicantId: "w1", status: "rejected" }, + { ...base, applicationId: "a2", applicantId: "w2", status: "rejected" }, + ]); + + expect(res.sent).toBe(1); + expect(mockSend.mock.calls.map((c) => c[0].to)).toEqual(["w2@example.test"]); + }); + + it("ignores statuses that are not emailed and touches no table", async () => { + const { client, queries } = fakeSvc(() => ({ data: [], error: null }), { w1: "w1@example.test" }); + const res = await emailApplicantsAboutStatus(client, [ + { ...base, applicationId: "a1", applicantId: "w1", status: "reviewing" }, + ]); + expect(res.sent).toBe(0); + expect(mockSend).not.toHaveBeenCalled(); + expect(queries).toHaveLength(0); + }); + + it("skips applicants with no email address", async () => { + const { client } = fakeSvc(() => ({ data: [], error: null }), {}); + const res = await emailApplicantsAboutStatus(client, [ + { ...base, applicationId: "a1", applicantId: "w1", status: "shortlisted" }, + ]); + expect(res.sent).toBe(0); + expect(mockSend).not.toHaveBeenCalled(); + }); +}); + +// ── New application: instant vs digest ─────────────────────────────── + +describe("notifyPosterOfNewApplication", () => { + const notice = { + gigId: "gig-1", + gigTitle: "Logo", + posterId: "p1", + posterName: "Pat", + applicationId: "app-new", + applicationMetadata: { source: "cli" }, + applicantName: "Wren", + coverLetter: "I design logos", + }; + const now = new Date("2026-10-06T12:00:00Z"); + + function svcWith(settings: Record | null, othersInWindow: number) { + return fakeSvc( + (q) => { + if (q.table === "notification_settings") return { data: settings, error: settings ? null : { code: "PGRST116" } }; + if (q.table === "applications" && q.calls.some((c) => c.method === "update")) return { error: null }; + if (q.table === "applications") return { count: othersInWindow, error: null }; + return { data: null, error: null }; + }, + { p1: "p1@example.test" } + ); + } + + it("sends instantly for the first application on the gig in 24h and stamps it", async () => { + const svc = svcWith(null, 0); + const outcome = await notifyPosterOfNewApplication(svc.client, notice, now); + + expect(outcome).toBe("instant"); + const countQuery = svc.queries.find((q) => q.table === "applications")!; + expect(has(countQuery, "eq", "gig_id", "gig-1")).toBe(true); + expect(has(countQuery, "neq", "id", "app-new")).toBe(true); + expect(has(countQuery, "gte", "created_at", "2026-10-05T12:00:00.000Z")).toBe(true); + // A held application the poster never heard about does not count. + expect(has(countQuery, "is", "metadata->>held", null)).toBe(true); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend.mock.calls[0][0].unsubscribe).toEqual({ userId: "p1", setting: "email_new_application" }); + + const [stamp] = updates(svc.queries); + const payload = stamp.calls.find((c) => c.method === "update")!.args[0] as { metadata: Record }; + expect(payload.metadata).toEqual({ + source: "cli", + poster_emailed: "instant", + poster_emailed_at: "2026-10-06T12:00:00.000Z", + }); + expect(has(stamp, "eq", "id", "app-new")).toBe(true); + }); + + it("leaves later applications for the digest", async () => { + const svc = svcWith(null, 3); + expect(await notifyPosterOfNewApplication(svc.client, notice, now)).toBe("digest"); + expect(mockSend).not.toHaveBeenCalled(); + expect(updates(svc.queries)).toHaveLength(0); + }); + + it("sends nothing when the poster turned email_new_application off", async () => { + const svc = svcWith({ user_id: "p1", email_new_application: false }, 0); + expect(await notifyPosterOfNewApplication(svc.client, notice, now)).toBe("disabled"); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it("does not stamp the application when the send fails, so the digest can retry", async () => { + mockSend.mockResolvedValue({ success: false, error: "provider down" } as never); + const svc = svcWith(null, 0); + expect(await notifyPosterOfNewApplication(svc.client, notice, now)).toBe("failed"); + expect(updates(svc.queries)).toHaveLength(0); + }); +}); + +// ── Daily digest ────────────────────────────────────────────────────── + +describe("runApplicationDigest", () => { + const now = new Date("2026-10-07T08:00:00Z"); + const gigA = { id: "gig-a", title: "Logo", poster_id: "p1" }; + const gigB = { id: "gig-b", title: "Website", poster_id: "p1" }; + const gigC = { id: "gig-c", title: "Copy", poster_id: "p2" }; + + const rows = [ + { id: "a1", gig_id: "gig-a", applicant_id: "w1", cover_letter: "first", metadata: { poster_emailed: "instant" }, created_at: "", gig: gigA }, + { id: "a2", gig_id: "gig-a", applicant_id: "w2", cover_letter: "second", metadata: null, created_at: "", gig: gigA }, + { id: "a3", gig_id: "gig-b", applicant_id: "w3", cover_letter: "third", metadata: { tx: "keep" }, created_at: "", gig: gigB }, + { id: "a4", gig_id: "gig-c", applicant_id: "w4", cover_letter: "fourth", metadata: null, created_at: "", gig: gigC }, + { id: "a5", gig_id: "gig-c", applicant_id: "w5", cover_letter: "spammy", metadata: { held: "spam_review" }, created_at: "", gig: gigC }, + ]; + + function svcFor(optOuts: string[] = [], emails: Record = { p1: "p1@example.test", p2: "p2@example.test" }) { + return fakeSvc((q) => { + if (q.table === "applications" && q.calls.some((c) => c.method === "update")) return { error: null }; + if (q.table === "applications") return { data: rows, error: null }; + if (q.table === "notification_settings") + return { data: optOuts.map((id) => ({ user_id: id, email_new_application: false })), error: null }; + if (q.table === "profiles") + return { + data: [ + { id: "p1", full_name: "Pat", username: "pat" }, + { id: "w2", full_name: "Wren", username: "wren" }, + { id: "w3", full_name: null, username: "wolf" }, + ], + error: null, + }; + return { data: null, error: null }; + }, emails); + } + + it("dry run reports what would be sent and writes nothing", async () => { + const svc = svcFor(); + const result = await runApplicationDigest(svc.client, { dryRun: true, now }); + + expect(result.dry_run).toBe(true); + expect(result.window_start).toBe("2026-10-06T08:00:00.000Z"); + expect(result.totals).toEqual({ applications: 3, posters: 2, sent: 0, would_send: 2, skipped: 0 }); + expect(result.posters).toEqual([ + { poster_id: "p1", gigs: 2, applications: 2, outcome: "would_send" }, + { poster_id: "p2", gigs: 1, applications: 1, outcome: "would_send" }, + ]); + expect(mockSend).not.toHaveBeenCalled(); + expect(updates(svc.queries)).toHaveLength(0); + + const listQuery = svc.queries.find((q) => q.table === "applications")!; + expect(has(listQuery, "neq", "status", "withdrawn")).toBe(true); + expect(has(listQuery, "is", "metadata->>held", null)).toBe(true); + expect(has(listQuery, "gte", "created_at", "2026-10-06T08:00:00.000Z")).toBe(true); + expect(has(listQuery, "lt", "created_at", "2026-10-07T08:00:00.000Z")).toBe(true); + }); + + it("sends one digest per poster, skipping instantly-emailed applications, and stamps them", async () => { + const svc = svcFor(); + const result = await runApplicationDigest(svc.client, { now }); + + expect(result.totals.sent).toBe(2); + expect(mockSend).toHaveBeenCalledTimes(2); + const toP1 = mockSend.mock.calls.find((c) => c[0].to === "p1@example.test")![0]; + expect(toP1.subject).toBe("2 new applications on 2 gigs - ugig.net"); + expect(toP1.html).toContain("Wren"); + expect(toP1.html).toContain("wolf"); + expect(toP1.html).not.toContain("first"); // a1 was already emailed instantly + expect(toP1.unsubscribe).toEqual({ userId: "p1", setting: "email_new_application" }); + + const stamped = updates(svc.queries).map((q) => { + const id = q.calls.find((c) => c.method === "eq")!.args[1]; + const meta = (q.calls.find((c) => c.method === "update")!.args[0] as { metadata: Record }).metadata; + return { id, meta }; + }); + expect(stamped.map((s) => s.id).sort()).toEqual(["a2", "a3", "a4"]); + expect(stamped.find((s) => s.id === "a3")!.meta).toEqual({ + tx: "keep", + poster_emailed: "digest", + poster_emailed_at: "2026-10-07T08:00:00.000Z", + }); + }); + + it("respects email_new_application and missing addresses", async () => { + const svc = svcFor(["p2"], { p1: "p1@example.test" }); + const result = await runApplicationDigest(svc.client, { now }); + // p2 opted out; p1 is sent + expect(result.posters.find((p) => p.poster_id === "p2")!.outcome).toBe("disabled"); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(updates(svc.queries).map((q) => q.calls.find((c) => c.method === "eq")!.args[1]).sort()).toEqual(["a2", "a3"]); + }); + + it("leaves applications unstamped when the digest send fails", async () => { + mockSend.mockResolvedValue({ success: false, error: "down" } as never); + const svc = svcFor(); + const result = await runApplicationDigest(svc.client, { now }); + expect(result.totals.sent).toBe(0); + expect(result.posters.every((p) => p.outcome === "failed")).toBe(true); + expect(updates(svc.queries)).toHaveLength(0); + }); + + it("throws when the applications query fails", async () => { + const svc = fakeSvc(() => ({ data: null, error: { message: "boom" } })); + await expect(runApplicationDigest(svc.client, { now })).rejects.toThrow(/boom/); + }); +}); diff --git a/src/lib/application-emails.ts b/src/lib/application-emails.ts new file mode 100644 index 00000000..a593e943 --- /dev/null +++ b/src/lib/application-emails.ts @@ -0,0 +1,385 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { + sendEmail, + applicationStatusEmail, + newApplicationEmail, + applicationDigestEmail, + type ApplicationDigestGig, +} from "@/lib/email"; +import { isEmailNotificationEnabled } from "@/lib/notification-settings"; +import { emailOptOutIds } from "@/lib/broadcast/audiences"; +import { createServiceClient } from "@/lib/supabase/service"; +import { HELD_COLUMN } from "@/lib/limits"; + +type Client = SupabaseClient; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Application statuses that email the applicant (when the setting is on). */ +export const STATUSES_EMAILED_TO_APPLICANT = ["accepted", "rejected", "shortlisted"] as const; + +export function statusEmailsApplicant(status: string): boolean { + return (STATUSES_EMAILED_TO_APPLICANT as readonly string[]).includes(status); +} + +/** + * Key in applications.metadata recording that the poster has already been + * emailed about this application ("instant" or "digest"), so the daily digest + * never repeats it. + */ +export const POSTER_EMAILED_KEY = "poster_emailed"; + +function asObject(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +async function getAuthEmail(svc: Client, userId: string): Promise { + try { + const { data } = await svc.auth.admin.getUserById(userId); + return data?.user?.email ?? null; + } catch { + return null; + } +} + +async function profileNames(svc: Client, ids: string[]): Promise> { + const names = new Map(); + if (ids.length === 0) return names; + for (let i = 0; i < ids.length; i += 500) { + const { data } = await svc + .from("profiles") + .select("id, full_name, username") + .in("id", ids.slice(i, i + 500)); + for (const row of (data ?? []) as { + id: string; + full_name: string | null; + username: string | null; + }[]) { + const name = row.full_name || row.username; + if (name) names.set(row.id, name); + } + } + return names; +} + +// ── Applicant status emails ────────────────────────────────────────── + +export interface ApplicantStatusChange { + applicationId: string; + applicantId: string; + gigId: string; + gigTitle: string; + posterName: string; + status: string; +} + +/** + * Email applicants whose application became accepted, rejected or + * shortlisted, honouring each applicant's `email_application_status` setting. + * Other statuses are ignored. The in-app notification is the DB trigger's job, + * not this function's. + */ +export async function emailApplicantsAboutStatus( + svc: Client, + changes: ApplicantStatusChange[] +): Promise<{ sent: number; skipped: number }> { + const eligible = changes.filter((c) => statusEmailsApplicant(c.status)); + if (eligible.length === 0) return { sent: 0, skipped: changes.length }; + + const applicantIds = [...new Set(eligible.map((c) => c.applicantId))]; + const optedOut = await emailOptOutIds(svc, applicantIds, "email_application_status"); + const names = await profileNames(svc, applicantIds); + + let sent = 0; + for (const change of eligible) { + if (optedOut.has(change.applicantId)) continue; + const to = await getAuthEmail(svc, change.applicantId); + if (!to) continue; + + const content = applicationStatusEmail({ + applicantName: names.get(change.applicantId) || "there", + gigTitle: change.gigTitle, + gigId: change.gigId, + status: change.status, + posterName: change.posterName, + }); + const result = await sendEmail({ + to, + ...content, + unsubscribe: { userId: change.applicantId, setting: "email_application_status" }, + }); + if (result.success) sent++; + } + + return { sent, skipped: changes.length - sent }; +} + +/** + * Fire-and-forget wrapper for routes: never throws, never delays the response. + */ +export function emailApplicantsAboutStatusInBackground(changes: ApplicantStatusChange[]): void { + if (!changes.some((c) => statusEmailsApplicant(c.status))) return; + void (async () => { + try { + await emailApplicantsAboutStatus(createServiceClient(), changes); + } catch (err) { + console.error("[application-emails] status email failed:", err); + } + })(); +} + +// ── New-application emails to posters ──────────────────────────────── + +export interface NewApplicationNotice { + gigId: string; + gigTitle: string; + posterId: string; + posterName: string; + applicationId: string; + applicationMetadata?: unknown; + applicantName: string; + coverLetter: string; +} + +export type NewApplicationEmailOutcome = "instant" | "digest" | "disabled" | "no_email" | "failed"; + +/** + * Email the poster about a new application, but only instantly for the first + * application on the gig in the last 24 hours. Every other application waits + * for the daily digest (POST /api/cron/application-digest). Honours the + * poster's `email_new_application` setting. + */ +export async function notifyPosterOfNewApplication( + svc: Client, + notice: NewApplicationNotice, + now: Date = new Date() +): Promise { + const enabled = await isEmailNotificationEnabled(svc, notice.posterId, "email_new_application"); + if (!enabled) return "disabled"; + + const since = new Date(now.getTime() - DAY_MS).toISOString(); + const { count, error } = await svc + .from("applications") + .select("id", { count: "exact", head: true }) + .eq("gig_id", notice.gigId) + .neq("id", notice.applicationId) + .gte("created_at", since) + // Held applications were never shown to the poster, so they don't count. + .is(HELD_COLUMN, null); + + // On a failed count, fall back to an instant email rather than risk the + // poster hearing nothing at all. + if (!error && (count ?? 0) > 0) return "digest"; + + const to = await getAuthEmail(svc, notice.posterId); + if (!to) return "no_email"; + + const content = newApplicationEmail({ + posterName: notice.posterName, + applicantName: notice.applicantName, + gigTitle: notice.gigTitle, + gigId: notice.gigId, + applicationId: notice.applicationId, + coverLetterPreview: notice.coverLetter, + }); + const result = await sendEmail({ + to, + ...content, + unsubscribe: { userId: notice.posterId, setting: "email_new_application" }, + }); + if (!result.success) return "failed"; + + await svc + .from("applications") + .update({ + metadata: { + ...asObject(notice.applicationMetadata), + [POSTER_EMAILED_KEY]: "instant", + poster_emailed_at: now.toISOString(), + }, + }) + .eq("id", notice.applicationId); + + return "instant"; +} + +export function notifyPosterOfNewApplicationInBackground(notice: NewApplicationNotice): void { + void (async () => { + try { + await notifyPosterOfNewApplication(createServiceClient(), notice); + } catch (err) { + console.error("[application-emails] new application email failed:", err); + } + })(); +} + +// ── Daily digest ───────────────────────────────────────────────────── + +export interface DigestPosterResult { + poster_id: string; + gigs: number; + applications: number; + outcome: "sent" | "would_send" | "disabled" | "no_email" | "failed"; +} + +export interface DigestResult { + dry_run: boolean; + window_start: string; + window_end: string; + posters: DigestPosterResult[]; + totals: { + applications: number; + posters: number; + sent: number; + would_send: number; + skipped: number; + }; +} + +interface DigestRow { + id: string; + gig_id: string; + applicant_id: string; + cover_letter: string | null; + metadata: unknown; + created_at: string; + gig: { id: string; title: string | null; poster_id: string } | { id: string; title: string | null; poster_id: string }[] | null; +} + +/** + * Build and (unless dryRun) send one digest per poster covering applications + * received in the 24 hours before `now` that were not already emailed. + * Sent applications are stamped in metadata so a second run sends nothing. + */ +export async function runApplicationDigest( + svc: Client, + { dryRun = false, now = new Date() }: { dryRun?: boolean; now?: Date } = {} +): Promise { + const windowStart = new Date(now.getTime() - DAY_MS).toISOString(); + const windowEnd = now.toISOString(); + + const rows: DigestRow[] = []; + const PAGE = 1000; + for (let from = 0; ; from += PAGE) { + const { data, error } = await svc + .from("applications") + .select("id, gig_id, applicant_id, cover_letter, metadata, created_at, gig:gigs(id, title, poster_id)") + .gte("created_at", windowStart) + .lt("created_at", windowEnd) + .neq("status", "withdrawn") + .is(HELD_COLUMN, null) + .order("created_at", { ascending: true }) + .range(from, from + PAGE - 1); + if (error) throw new Error(`applications query failed: ${error.message}`); + const page = (data ?? []) as DigestRow[]; + rows.push(...page); + if (page.length < PAGE) break; + } + + // poster -> gig -> rows + const byPoster = new Map>(); + for (const row of rows) { + const meta = asObject(row.metadata); + // Already emailed, or held for spam review (the poster never hears of those). + if (meta[POSTER_EMAILED_KEY] || meta.held) continue; + const gig = Array.isArray(row.gig) ? row.gig[0] : row.gig; + if (!gig?.poster_id) continue; + let gigs = byPoster.get(gig.poster_id); + if (!gigs) byPoster.set(gig.poster_id, (gigs = new Map())); + let entry = gigs.get(gig.id); + if (!entry) gigs.set(gig.id, (entry = { title: gig.title || "Your gig", rows: [] })); + entry.rows.push(row); + } + + const posterIds = [...byPoster.keys()]; + const optedOut = posterIds.length + ? await emailOptOutIds(svc, posterIds, "email_new_application") + : new Set(); + const applicantIds = [ + ...new Set([...byPoster.values()].flatMap((g) => [...g.values()].flatMap((e) => e.rows.map((r) => r.applicant_id)))), + ]; + const names = await profileNames(svc, [...new Set([...posterIds, ...applicantIds])]); + + const result: DigestResult = { + dry_run: dryRun, + window_start: windowStart, + window_end: windowEnd, + posters: [], + totals: { applications: 0, posters: posterIds.length, sent: 0, would_send: 0, skipped: 0 }, + }; + + for (const [posterId, gigs] of byPoster) { + const gigList = [...gigs.entries()]; + const appRows = gigList.flatMap(([, e]) => e.rows); + result.totals.applications += appRows.length; + const summary = { poster_id: posterId, gigs: gigList.length, applications: appRows.length }; + + if (optedOut.has(posterId)) { + result.posters.push({ ...summary, outcome: "disabled" }); + result.totals.skipped++; + continue; + } + + const to = await getAuthEmail(svc, posterId); + if (!to) { + result.posters.push({ ...summary, outcome: "no_email" }); + result.totals.skipped++; + continue; + } + + if (dryRun) { + result.posters.push({ ...summary, outcome: "would_send" }); + result.totals.would_send++; + continue; + } + + const digestGigs: ApplicationDigestGig[] = gigList.map(([gigId, e]) => ({ + gigId, + gigTitle: e.title, + applicants: e.rows.map((r) => ({ + name: names.get(r.applicant_id) || "A candidate", + coverLetterPreview: r.cover_letter || "", + })), + })); + + const content = applicationDigestEmail({ + posterName: names.get(posterId) || "there", + gigs: digestGigs, + }); + const sendResult = await sendEmail({ + to, + ...content, + unsubscribe: { userId: posterId, setting: "email_new_application" }, + }); + if (!sendResult.success) { + result.posters.push({ ...summary, outcome: "failed" }); + result.totals.skipped++; + continue; + } + + const stampedAt = now.toISOString(); + for (let i = 0; i < appRows.length; i += 10) { + await Promise.all( + appRows.slice(i, i + 10).map((r) => + svc + .from("applications") + .update({ + metadata: { + ...asObject(r.metadata), + [POSTER_EMAILED_KEY]: "digest", + poster_emailed_at: stampedAt, + }, + }) + .eq("id", r.id) + ) + ); + } + + result.posters.push({ ...summary, outcome: "sent" }); + result.totals.sent++; + } + + return result; +} diff --git a/src/lib/application-resolution.test.ts b/src/lib/application-resolution.test.ts new file mode 100644 index 00000000..f7dcb555 --- /dev/null +++ b/src/lib/application-resolution.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi } from "vitest"; +import { rejectOpenApplications } from "./application-resolution"; + +type Call = { method: string; args: unknown[] }; + +function fakeClient(open: { id: string; applicant_id: string; status: string; metadata: unknown }[], raced: string[] = []) { + const queries: Call[][] = []; + const from = vi.fn(() => { + const calls: Call[] = []; + queries.push(calls); + const chain: Record = {}; + for (const m of ["select", "update", "eq", "in", "is"]) { + chain[m] = (...args: unknown[]) => { + calls.push({ method: m, args }); + return chain; + }; + } + chain.then = (ok: (v: unknown) => unknown) => { + const isUpdate = calls.some((c) => c.method === "update"); + if (!isUpdate) return Promise.resolve({ data: open, error: null }).then(ok); + const id = calls.find((c) => c.method === "eq")!.args[1] as string; + return Promise.resolve({ data: raced.includes(id) ? [] : [{ id }], error: null }).then(ok); + }; + return chain; + }); + return { client: { from } as never, queries }; +} + +const now = new Date("2026-10-06T13:00:00Z"); + +describe("rejectOpenApplications", () => { + const open = [ + { id: "a1", applicant_id: "w1", status: "pending", metadata: null }, + { id: "a2", applicant_id: "w2", status: "shortlisted", metadata: { tx_id: "keep" } }, + ]; + + it("only looks at pending, reviewing and shortlisted applications on the gig", async () => { + const { client, queries } = fakeClient(open); + await rejectOpenApplications(client, "gig-1", "gig_closed", { now }); + const select = queries[0]; + expect(select).toContainEqual({ method: "eq", args: ["gig_id", "gig-1"] }); + expect(select).toContainEqual({ method: "in", args: ["status", ["pending", "reviewing", "shortlisted"]] }); + // Held (spam review) applications are left alone. + expect(select).toContainEqual({ method: "is", args: ["metadata->>held", null] }); + }); + + it("rejects each one with the reason, preserving existing metadata", async () => { + const { client, queries } = fakeClient(open); + const resolved = await rejectOpenApplications(client, "gig-1", "gig_filled", { now }); + + expect(resolved).toEqual([ + { id: "a1", applicant_id: "w1", previous_status: "pending" }, + { id: "a2", applicant_id: "w2", previous_status: "shortlisted" }, + ]); + const updatesById = Object.fromEntries( + queries.slice(1).map((calls) => [ + calls.find((c) => c.method === "eq")!.args[1], + calls.find((c) => c.method === "update")!.args[0], + ]) + ); + expect(updatesById.a1).toEqual({ + status: "rejected", + updated_at: "2026-10-06T13:00:00.000Z", + metadata: { reason: "gig_filled", resolved_at: "2026-10-06T13:00:00.000Z" }, + }); + expect((updatesById.a2 as { metadata: unknown }).metadata).toEqual({ + tx_id: "keep", + reason: "gig_filled", + resolved_at: "2026-10-06T13:00:00.000Z", + }); + // Guarded on still being open + for (const calls of queries.slice(1)) { + expect(calls).toContainEqual({ method: "in", args: ["status", ["pending", "reviewing", "shortlisted"]] }); + } + }); + + it("does not report an application that changed status in the meantime", async () => { + const { client } = fakeClient(open, ["a2"]); + const resolved = await rejectOpenApplications(client, "gig-1", "gig_closed", { now }); + expect(resolved.map((r) => r.id)).toEqual(["a1"]); + }); + + it("writes nothing in dry-run mode", async () => { + const { client, queries } = fakeClient(open); + const resolved = await rejectOpenApplications(client, "gig-1", "gig_closed", { dryRun: true, now }); + expect(resolved).toHaveLength(2); + expect(queries).toHaveLength(1); + }); +}); diff --git a/src/lib/application-resolution.ts b/src/lib/application-resolution.ts new file mode 100644 index 00000000..79747bb6 --- /dev/null +++ b/src/lib/application-resolution.ts @@ -0,0 +1,80 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { OPEN_APPLICATION_STATUSES } from "@/lib/application-status"; +import { HELD_COLUMN } from "@/lib/limits"; + +type Client = SupabaseClient; + +export type ResolutionReason = "gig_closed" | "gig_filled"; + +export interface ResolvedApplication { + id: string; + applicant_id: string; + previous_status: string; +} + +/** + * Reject every application on a gig that is still pending, reviewing or + * shortlisted, recording why in metadata.reason. The + * notify_on_application_status_change trigger then gives each applicant one + * in-app notification. Existing metadata keys are preserved. + * + * Applications held for spam review are left alone: the poster never saw + * them and the applicant should not be notified about them. + * + * Each row is updated on the condition that it is still open, so an + * application the poster accepts in the meantime is left alone. + */ +export async function rejectOpenApplications( + client: Client, + gigId: string, + reason: ResolutionReason, + { dryRun = false, now = new Date() }: { dryRun?: boolean; now?: Date } = {} +): Promise { + const { data, error } = await client + .from("applications") + .select("id, applicant_id, status, metadata") + .eq("gig_id", gigId) + .in("status", [...OPEN_APPLICATION_STATUSES]) + // Applications held for spam review stay with the reviewers. + .is(HELD_COLUMN, null); + + if (error) throw new Error(`open applications query failed: ${error.message}`); + + const open = (data ?? []) as { + id: string; + applicant_id: string; + status: string; + metadata: unknown; + }[]; + if (dryRun || open.length === 0) { + return open.map((a) => ({ id: a.id, applicant_id: a.applicant_id, previous_status: a.status })); + } + + const resolved: ResolvedApplication[] = []; + const stamp = now.toISOString(); + for (let i = 0; i < open.length; i += 10) { + const batch = open.slice(i, i + 10); + const results = await Promise.all( + batch.map(async (app) => { + const metadata = + app.metadata && typeof app.metadata === "object" && !Array.isArray(app.metadata) + ? (app.metadata as Record) + : {}; + const { data: updated, error: updateError } = await client + .from("applications") + .update({ + status: "rejected", + updated_at: stamp, + metadata: { ...metadata, reason, resolved_at: stamp }, + }) + .eq("id", app.id) + .in("status", [...OPEN_APPLICATION_STATUSES]) + .select("id"); + if (updateError || !updated || (updated as unknown[]).length === 0) return null; + return { id: app.id, applicant_id: app.applicant_id, previous_status: app.status }; + }) + ); + for (const r of results) if (r) resolved.push(r); + } + return resolved; +} diff --git a/src/lib/application-status.ts b/src/lib/application-status.ts index 566c5967..0045455e 100644 --- a/src/lib/application-status.ts +++ b/src/lib/application-status.ts @@ -38,3 +38,13 @@ export const STATUSES_NOTIFIED_BY_TRIGGER = [ export function triggerNotifiesStatus(status: string): boolean { return (STATUSES_NOTIFIED_BY_TRIGGER as readonly string[]).includes(status); } + +/** + * Statuses that mean the application is still open: undecided and not + * withdrawn. Closing, filling or deleting a gig resolves these. + */ +export const OPEN_APPLICATION_STATUSES = ["pending", "reviewing", "shortlisted"] as const; + +export function isOpenApplicationStatus(status: string | null | undefined): boolean { + return !!status && (OPEN_APPLICATION_STATUSES as readonly string[]).includes(status); +} diff --git a/src/lib/email-unsubscribe.test.ts b/src/lib/email-unsubscribe.test.ts new file mode 100644 index 00000000..f585add5 --- /dev/null +++ b/src/lib/email-unsubscribe.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const sendMock = vi.fn(); +vi.mock("@profullstack/stack/email", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createEmailer: vi.fn(() => ({ send: sendMock })), + }; +}); + +import { + createUnsubscribeToken, + verifyUnsubscribeToken, + unsubscribeUrl, +} from "./email-unsubscribe"; +import { + sendEmail, + withUnsubscribe, + applicationDigestEmail, + applicationStatusEmail, + newApplicationEmail, +} from "./email"; + +const USER = "11111111-2222-4333-8444-555555555555"; + +beforeEach(() => { + vi.stubEnv("UNSUBSCRIBE_SECRET", "test-unsubscribe-secret"); + vi.stubEnv("CRON_SECRET", "test-cron-secret"); + vi.stubEnv("APP_URL", "https://ugig.net"); + sendMock.mockReset(); + sendMock.mockResolvedValue({ sent: true, id: "msg-1" }); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("unsubscribe tokens", () => { + it("round-trips the user and setting", () => { + const token = createUnsubscribeToken(USER, "email_application_status")!; + expect(verifyUnsubscribeToken(token)).toEqual({ + userId: USER, + setting: "email_application_status", + }); + }); + + it("rejects a token whose payload was changed", () => { + const token = createUnsubscribeToken(USER, "email_new_message")!; + const [, sig] = token.split("."); + const forged = Buffer.from(`${USER}:email_new_application`).toString("base64url"); + expect(verifyUnsubscribeToken(`${forged}.${sig}`)).toBeNull(); + }); + + it("rejects a token signed with another secret", () => { + const token = createUnsubscribeToken(USER, "email_new_message")!; + vi.stubEnv("UNSUBSCRIBE_SECRET", "rotated"); + expect(verifyUnsubscribeToken(token)).toBeNull(); + }); + + it("rejects garbage, empty and unknown-setting tokens", () => { + expect(verifyUnsubscribeToken(null)).toBeNull(); + expect(verifyUnsubscribeToken("")).toBeNull(); + expect(verifyUnsubscribeToken("abc")).toBeNull(); + expect(verifyUnsubscribeToken("a.b.c")).toBeNull(); + }); + + it("falls back to CRON_SECRET when UNSUBSCRIBE_SECRET is unset", () => { + vi.stubEnv("UNSUBSCRIBE_SECRET", ""); + const token = createUnsubscribeToken(USER, "email_new_follower")!; + expect(token).toBeTruthy(); + expect(verifyUnsubscribeToken(token)?.setting).toBe("email_new_follower"); + }); + + it("produces no token or link without any secret", () => { + vi.stubEnv("UNSUBSCRIBE_SECRET", ""); + vi.stubEnv("CRON_SECRET", ""); + expect(createUnsubscribeToken(USER, "email_new_follower")).toBeNull(); + expect(unsubscribeUrl("https://ugig.net", USER, "email_new_follower")).toBeNull(); + }); + + it("builds the unsubscribe URL on the API route", () => { + const url = unsubscribeUrl("https://ugig.net", USER, "email_mention")!; + expect(url.startsWith("https://ugig.net/api/email/unsubscribe?token=")).toBe(true); + const token = decodeURIComponent(new URL(url).searchParams.get("token")!); + expect(verifyUnsubscribeToken(token)?.setting).toBe("email_mention"); + }); +}); + +describe("withUnsubscribe", () => { + it("adds a footer link, a text line and RFC 8058 headers", () => { + const out = withUnsubscribe( + { html: "

hi

", text: "hi\n" }, + { userId: USER, setting: "email_new_application" } + ); + expect(out.html).toContain("Unsubscribe from new application emails"); + expect(out.html).toContain("/api/email/unsubscribe?token="); + expect(out.html.indexOf("Unsubscribe")).toBeLessThan(out.html.indexOf("")); + expect(out.text).toContain("Unsubscribe from new application emails: https://ugig.net/api/email/unsubscribe?token="); + expect(out.headers?.["List-Unsubscribe"]).toMatch(/^$/); + expect(out.headers?.["List-Unsubscribe-Post"]).toBe("List-Unsubscribe=One-Click"); + }); + + it("leaves content unchanged when no secret is configured", () => { + vi.stubEnv("UNSUBSCRIBE_SECRET", ""); + vi.stubEnv("CRON_SECRET", ""); + const content = { html: "x", text: "x" }; + expect(withUnsubscribe(content, { userId: USER, setting: "email_mention" })).toEqual(content); + }); +}); + +describe("sendEmail with unsubscribe", () => { + it("passes the List-Unsubscribe headers to the emailer", async () => { + vi.stubEnv("RESEND_API_KEY", "re_test"); + await sendEmail({ + to: "someone@example.test", + subject: "s", + html: "x", + text: "x", + unsubscribe: { userId: USER, setting: "email_application_status" }, + }); + expect(sendMock).toHaveBeenCalledTimes(1); + const sent = sendMock.mock.calls[0][0]; + expect(sent.headers["List-Unsubscribe-Post"]).toBe("List-Unsubscribe=One-Click"); + expect(sent.headers["List-Unsubscribe"]).toContain("/api/email/unsubscribe?token="); + expect(sent.html).toContain("Unsubscribe from application status emails"); + }); + + it("sends no unsubscribe headers for transactional email", async () => { + vi.stubEnv("RESEND_API_KEY", "re_test"); + await sendEmail({ to: "someone@example.test", subject: "s", html: "

x

" }); + expect(sendMock.mock.calls[0][0].headers).toBeUndefined(); + }); +}); + +describe("application email templates", () => { + it("escapes user-controlled text in the status email", () => { + const out = applicationStatusEmail({ + applicantName: "A", + gigTitle: "", + gigId: "gig-1", + status: "rejected", + posterName: "P&Q", + }); + expect(out.html).not.toContain(""); + expect(out.html).toContain("<script>"); + expect(out.html).toContain("P&Q"); + }); + + it("escapes the cover letter in the new application email", () => { + const out = newApplicationEmail({ + posterName: "P", + applicantName: "A", + gigTitle: "G", + gigId: "gig-1", + applicationId: "app-1", + coverLetterPreview: '', + }); + expect(out.html).not.toContain(" { + const out = applicationDigestEmail({ + posterName: "Pat", + gigs: [ + { + gigId: "gig-1", + gigTitle: "Logo", + applicants: Array.from({ length: 7 }, (_, i) => ({ + name: `Applicant ${i + 1}`, + coverLetterPreview: "I can help", + })), + }, + { gigId: "gig-2", gigTitle: "Site", applicants: [{ name: "Solo", coverLetterPreview: "hi" }] }, + ], + }); + expect(out.subject).toBe("8 new applications on 2 gigs - ugig.net"); + expect(out.html).toContain("Applicant 5"); + expect(out.html).not.toContain("Applicant 6"); + expect(out.html).toContain("and 2 more"); + expect(out.html).toContain("https://ugig.net/gigs/gig-1/applications"); + expect(out.html).toContain("<i>Site</i>"); + expect(out.text).toContain("Logo (7)"); + }); + + it("uses singular wording for one application", () => { + const out = applicationDigestEmail({ + posterName: "Pat", + gigs: [{ gigId: "g", gigTitle: "T", applicants: [{ name: "A", coverLetterPreview: "" }] }], + }); + expect(out.subject).toBe("1 new application on 1 gig - ugig.net"); + }); +}); diff --git a/src/lib/email-unsubscribe.ts b/src/lib/email-unsubscribe.ts new file mode 100644 index 00000000..dfbc0cf3 --- /dev/null +++ b/src/lib/email-unsubscribe.ts @@ -0,0 +1,82 @@ +import { createHmac, timingSafeEqual } from "crypto"; +import type { NotificationSettingKey } from "@/lib/notification-settings"; + +/** + * Signed one-click unsubscribe links for notification emails. + * + * A token names one user and one notification setting, signed with + * UNSUBSCRIBE_SECRET (falling back to CRON_SECRET, so a deploy without the new + * variable still produces working links). Tokens do not expire: an unsubscribe + * link in a months-old email must still work. + * + * Token format: base64url(":") + "." + base64url(hmac-sha256) + */ + +export const UNSUBSCRIBABLE_SETTINGS: Record = { + email_new_message: "new message emails", + email_new_comment: "comment emails", + email_new_follower: "new follower emails", + email_new_application: "new application emails", + email_application_status: "application status emails", + email_review_received: "review emails", + email_endorsement_received: "endorsement emails", + email_gig_updates: "gig update emails", + email_mention: "mention emails", + email_upvote_milestone: "upvote milestone emails", +}; + +export function isUnsubscribableSetting(value: string): value is NotificationSettingKey { + return Object.prototype.hasOwnProperty.call(UNSUBSCRIBABLE_SETTINGS, value); +} + +function getSecret(): string | null { + return process.env.UNSUBSCRIBE_SECRET || process.env.CRON_SECRET || null; +} + +function sign(payload: string, secret: string): string { + return createHmac("sha256", secret).update(payload).digest("base64url"); +} + +export function createUnsubscribeToken( + userId: string, + setting: NotificationSettingKey +): string | null { + const secret = getSecret(); + if (!secret) return null; + const payload = Buffer.from(`${userId}:${setting}`).toString("base64url"); + return `${payload}.${sign(payload, secret)}`; +} + +export function verifyUnsubscribeToken( + token: string | null | undefined +): { userId: string; setting: NotificationSettingKey } | null { + const secret = getSecret(); + if (!secret || !token) return null; + + const [payload, signature, ...rest] = token.split("."); + if (!payload || !signature || rest.length > 0) return null; + + const expected = Buffer.from(sign(payload, secret)); + const given = Buffer.from(signature); + if (expected.length !== given.length || !timingSafeEqual(expected, given)) return null; + + const decoded = Buffer.from(payload, "base64url").toString("utf8"); + const sep = decoded.lastIndexOf(":"); + if (sep <= 0) return null; + const userId = decoded.slice(0, sep); + const setting = decoded.slice(sep + 1); + if (!isUnsubscribableSetting(setting)) return null; + + return { userId, setting }; +} + +/** Absolute unsubscribe URL, or null when no signing secret is configured. */ +export function unsubscribeUrl( + baseUrl: string, + userId: string, + setting: NotificationSettingKey +): string | null { + const token = createUnsubscribeToken(userId, setting); + if (!token) return null; + return `${baseUrl}/api/email/unsubscribe?token=${encodeURIComponent(token)}`; +} diff --git a/src/lib/email.ts b/src/lib/email.ts index 34f5c8d4..73f2c972 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -1,4 +1,6 @@ import { createEmailer, escapeHtml, type Emailer } from "@profullstack/stack/email"; +import type { NotificationSettingKey } from "@/lib/notification-settings"; +import { UNSUBSCRIBABLE_SETTINGS, unsubscribeUrl } from "@/lib/email-unsubscribe"; const SENDER_EMAIL = process.env.FROM_EMAIL || process.env.EMAIL_FROM || "notifications@ugig.net"; const SENDER_NAME = process.env.FROM_EMAIL_NAME || process.env.EMAIL_FROM_NAME; @@ -25,9 +27,45 @@ interface SendEmailParams { subject: string; html: string; text?: string; + /** + * The recipient and the notification setting this email belongs to. When + * set, the email carries a signed one-click unsubscribe link for that + * setting plus List-Unsubscribe / List-Unsubscribe-Post headers (RFC 8058). + */ + unsubscribe?: { userId: string; setting: NotificationSettingKey }; } -export async function sendEmail({ to, subject, html, text }: SendEmailParams) { +/** + * Add the unsubscribe footer and headers for one notification setting. + * Returns the content unchanged when no signing secret is configured. + */ +export function withUnsubscribe( + content: { html: string; text?: string }, + unsubscribe: { userId: string; setting: NotificationSettingKey } +): { html: string; text?: string; headers?: Record } { + const url = unsubscribeUrl(getBaseUrl(), unsubscribe.userId, unsubscribe.setting); + if (!url) return content; + + const label = UNSUBSCRIBABLE_SETTINGS[unsubscribe.setting]; + const footer = `

Unsubscribe from ${label}

`; + const html = content.html.includes("") + ? content.html.replace("", `${footer}\n`) + : `${content.html}\n${footer}`; + const text = content.text + ? `${content.text.trimEnd()}\nUnsubscribe from ${label}: ${url}\n` + : undefined; + + return { + html, + text, + headers: { + "List-Unsubscribe": `<${url}>`, + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }, + }; +} + +export async function sendEmail({ to, subject, html, text, unsubscribe }: SendEmailParams) { const emailer = getEmailer(); if (!emailer) { console.log("RESEND_API_KEY not configured, skipping email:", { to, subject }); @@ -35,6 +73,14 @@ export async function sendEmail({ to, subject, html, text }: SendEmailParams) { } try { + let headers: Record | undefined; + if (unsubscribe) { + const wrapped = withUnsubscribe({ html, text }, unsubscribe); + html = wrapped.html; + text = wrapped.text; + headers = wrapped.headers; + } + console.log("[email] Sending email:", { to, subject, from: FROM_EMAIL }); const result = await emailer.send({ from: FROM_EMAIL, @@ -42,6 +88,7 @@ export async function sendEmail({ to, subject, html, text }: SendEmailParams) { subject, html, text, + ...(headers ? { headers } : {}), }); if (!result.sent) { @@ -358,15 +405,15 @@ export function newApplicationEmail(params: {
-

Hi ${posterName},

+

Hi ${escapeHtml(posterName)},

-

${applicantName} has applied to your gig:

+

${escapeHtml(applicantName)} has applied to your gig:

-

${gigTitle}

+

${escapeHtml(gigTitle)}

Cover letter preview:
- "${coverLetterPreview.slice(0, 200)}${coverLetterPreview.length > 200 ? "..." : ""}" + "${escapeHtml(coverLetterPreview.slice(0, 200))}${coverLetterPreview.length > 200 ? "..." : ""}"

@@ -412,6 +459,101 @@ ugig.net - AI-Powered Gig Marketplace }; } +export interface ApplicationDigestGig { + gigId: string; + gigTitle: string; + applicants: { name: string; coverLetterPreview: string }[]; +} + +/** Daily digest of new applications for one poster, grouped by gig. */ +export function applicationDigestEmail(params: { + posterName: string; + gigs: ApplicationDigestGig[]; +}) { + const { posterName, gigs } = params; + const baseUrl = getBaseUrl(); + const total = gigs.reduce((n, g) => n + g.applicants.length, 0); + const summary = `${total} new application${total === 1 ? "" : "s"} on ${gigs.length} gig${gigs.length === 1 ? "" : "s"}`; + const MAX_LISTED = 5; + + const gigBlocks = gigs + .map((g) => { + const listed = g.applicants.slice(0, MAX_LISTED); + const more = g.applicants.length - listed.length; + const items = listed + .map( + (a) => + `
  • ${escapeHtml(a.name)}
    ${escapeHtml(a.coverLetterPreview.slice(0, 140))}${a.coverLetterPreview.length > 140 ? "..." : ""}
  • ` + ) + .join(""); + const moreLine = more > 0 ? `
  • and ${more} more
  • ` : ""; + return ` +
    +

    ${escapeHtml(g.gigTitle)}

    +

    ${g.applicants.length} new application${g.applicants.length === 1 ? "" : "s"}

    +
      ${items}${moreLine}
    + Review applications +
    `; + }) + .join(""); + + const html = ` + + + + + + Your daily applications digest + + +
    +

    ${summary}

    +
    + +
    +

    Hi ${escapeHtml(posterName)},

    +

    Here is what came in over the last 24 hours.

    + ${gigBlocks} +
    + +
    +

    ugig.net - AI-Powered Gig Marketplace

    +

    + Manage notification settings +

    +
    + + +`; + + const textGigs = gigs + .map((g) => { + const names = g.applicants.slice(0, MAX_LISTED).map((a) => ` - ${a.name}`).join("\n"); + const more = g.applicants.length > MAX_LISTED ? `\n - and ${g.applicants.length - MAX_LISTED} more` : ""; + return `${g.gigTitle} (${g.applicants.length})\n${names}${more}\nReview: ${baseUrl}/gigs/${g.gigId}/applications`; + }) + .join("\n\n"); + + const text = ` +${summary} + +Hi ${posterName}, + +Here is what came in over the last 24 hours. + +${textGigs} + +--- +ugig.net - AI-Powered Gig Marketplace +`; + + return { + subject: `${summary} - ugig.net`, + html, + text, + }; +} + export function invoiceReceivedEmail(params: { posterName: string; workerName: string; @@ -600,14 +742,14 @@ export function applicationStatusEmail(params: {
    -

    Hi ${applicantName},

    +

    Hi ${escapeHtml(applicantName)},

    ${statusInfo.message}

    -

    ${gigTitle}

    +

    ${escapeHtml(gigTitle)}

    - Posted by ${posterName} + Posted by ${escapeHtml(posterName)}

    diff --git a/src/lib/gigs/expire-gigs.test.ts b/src/lib/gigs/expire-gigs.test.ts index c6f87dca..01889077 100644 --- a/src/lib/gigs/expire-gigs.test.ts +++ b/src/lib/gigs/expire-gigs.test.ts @@ -135,6 +135,7 @@ describe("expireGigs", () => { expect(first.text).toContain("/gigs/1#renew"); expect(first.text).toContain("Renew it for another 30 days"); expect(first.text).toContain("3 applications"); + expect(first.unsubscribe).toEqual({ userId: "poster-1", setting: "email_gig_updates" }); // An ad renews for 60. expect(mockSendEmail.mock.calls[1][0].text).toContain("another 60 days"); }); diff --git a/src/lib/gigs/expire-gigs.ts b/src/lib/gigs/expire-gigs.ts index b66acd65..7f5054fa 100644 --- a/src/lib/gigs/expire-gigs.ts +++ b/src/lib/gigs/expire-gigs.ts @@ -105,6 +105,7 @@ export async function expireGigs( applicantCount: gig.applications_count ?? 0, renewDays: expiryDaysFor(gig.listing_type), }), + unsubscribe: { userId: gig.poster_id, setting: "email_gig_updates" }, }); if (sent && (sent as { success?: boolean }).success === false) { result.email_failed++;