Skip to content

Application notifications: status emails, poster digest, one-click unsubscribe, resolve on close/fill/delete - #598

Merged
ralyodio merged 1 commit into
masterfrom
feat/application-notifications
Oct 6, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feat/application-notifications

Conversation

@ralyodio

@ralyodio ralyodio commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Implements PRD 02 (application triage) items 1, 2, 6 and part of 3, plus PRD 08 unsubscribe and settings gating.

Changes

Applicant status email. applicationStatusEmail is now sent when an application becomes accepted, rejected or shortlisted. It goes out from PUT /api/applications/[id]/status, PUT /api/applications/bulk-status and POST /api/gigs/[id]/applications/approve-all. It is gated on email_application_status, skipped when the status did not actually change, and fire-and-forget, so an email error never fails the request. In-app notifications stay with the DB trigger, and no route-level inserts were added. User-controlled text in this template (and in newApplicationEmail) is now HTML-escaped.

Poster emails: instant first, then digest. Both apply routes now gate on email_new_application. A poster is emailed instantly only for the first application on a gig in the last 24h. Every other new application waits for one daily digest per poster:

  • Cron route: POST /api/cron/application-digest. It uses CRON_SECRET auth like daily-stats, and ?dry_run=1 returns the per-poster plan without sending.
  • Applications that have been emailed are stamped in applications.metadata.poster_emailed (instant or digest), so a re-run sends nothing new. No migration is needed.
  • New template: applicationDigestEmail.

One-click unsubscribe.

  • src/lib/email-unsubscribe.ts creates and verifies HMAC tokens. The secret is UNSUBSCRIBE_SECRET, falling back to CRON_SECRET. Tokens do not expire.
  • sendEmail({..., unsubscribe: { userId, setting } }) adds a footer link plus List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click headers. @profullstack/emailer passes headers through to Resend.
  • GET/POST /api/email/unsubscribe?token= turns off that one setting and shows a plain confirmation page.
  • The link is wired into every setting-tied email: new message (send, conversation, message-all, broadcast), application received, application status, digest, comment and reply (posts and gigs), mention, follower, endorsement, review, upvote milestone and gig filled.
  • Several of those routes never checked their setting before, so they now do. Otherwise the link would turn off a setting nothing reads.

Close, fill or delete resolves applications.

  • PATCH /api/gigs/[id]/status to closed or filled rejects every pending, reviewing or shortlisted application, with metadata.reason gig_closed or gig_filled. Existing metadata is kept. The trigger notifies each applicant in-app once, and the gated email from item 1 also goes out. The response includes resolved_applications.
  • DELETE /api/gigs/[id] returns 409 when the gig has a hired application or an unpaid sent invoice. Otherwise each open applicant gets one in-app application_status notice saying the gig was removed. These notices are inserted with the service client, which is the only role allowed to insert notifications, and only after the delete succeeds, so a failed delete tells no one their application is closed.

Backfill script. scripts/resolve-stranded-applications.ts is a dry run by default; --apply writes. It rejects open applications on closed or filled gigs with reason gig_closed, sends no email and prints counts. It has not been run. A read-only check of prod today found 389 stranded applications (387 pending, 2 reviewing) on 55 closed gigs.

UI.

  • Withdraw button on active applications in /dashboard/applications, using DELETE /api/applications/[id].
  • Multi-select with Select all, Shortlist and Reject on /gigs/[id]/applications, calling PUT /api/applications/bulk-status in batches of 50.

Ops

  • Cron to add: POST https://ugig.net/api/cron/application-digest with x-cron-secret, once a day. For example 0 16 * * * (UTC), next to the other ugig crontab entries.
  • Optional env var UNSUBSCRIBE_SECRET. Without it, links are signed with CRON_SECRET. Rotating whichever secret signs the links breaks existing links.
  • No migrations.

Not done

  • PUT /api/gigs/[id] can also set status. Only the status route resolves applications.
  • An application resubmitted after a withdrawal keeps its old created_at, so if it was not the instant email it misses the digest window.
  • profile-reminders and other non-setting emails get no "unsubscribe from all" yet (PRD 08 item 3, second half).
  • The compare view, sorting, "Reject all remaining" and stale auto-decline from PRD 02 are not in this PR.

Checks

Run by hand because the pre-commit hook is broken in fresh worktrees: npx tsc --noEmit is clean, eslint on the changed files shows 0 errors, and the full vitest run --no-file-parallelism passes (243 files, 2302 tests). New tests cover every changed route, the cron and unsubscribe routes, the libraries and both components.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

47 finding(s)

HIGH/CRITICAL: 1 | MEDIUM: 8 | LOW: 38

Severity Rule Location
HIGH js-ssrf-outbound-request scripts/scan-all-skills.ts:38
MEDIUM js-open-redirect src/app/agent-login/AgentLoginForm.tsx:38
MEDIUM js-unescaped-html-sink src/app/blog/[slug]/page.tsx:79
MEDIUM js-open-redirect src/app/dashboard/subscription/page.tsx:90
MEDIUM js-open-redirect src/app/dashboard/subscription/page.tsx:113
MEDIUM js-open-redirect src/app/dashboard/subscription/page.tsx:144
MEDIUM js-open-redirect src/components/funding/FundingClient.tsx:137
MEDIUM js-dynamic-code-execution src/lib/skills/metadata-extract.ts:300
MEDIUM js-dynamic-code-execution src/lib/skills/security-scan.ts:48
LOW secret-generic-credential cli/src/commands/auth.test.ts:66
LOW secret-generic-credential cli/src/commands/auth.test.ts:85
LOW secret-generic-api-key docs/agents/integration-guide.md:893
LOW secret-generic-credential src/app/api/auth/login/route.test.ts:53
LOW secret-generic-credential src/app/api/auth/login/route.test.ts:68
LOW secret-generic-credential src/app/api/auth/login/route.test.ts:87
LOW secret-generic-credential src/app/api/auth/signup/route.test.ts:158
LOW secret-generic-credential src/app/api/auth/signup/route.test.ts:182
LOW secret-generic-credential src/app/api/auth/signup/route.test.ts:193
LOW secret-generic-credential src/app/api/auth/signup/route.test.ts:232
LOW js-dynamic-code-execution src/app/api/skills/[slug]/scan/route.test.ts:212
LOW js-dynamic-code-execution src/app/api/skills/[slug]/scan/route.test.ts:223
LOW js-dynamic-code-execution src/app/api/skills/[slug]/scan/route.test.ts:239
LOW secret-generic-credential src/lib/api.test.ts:126
LOW secret-generic-credential src/lib/api.test.ts:131
LOW js-dynamic-code-execution src/lib/skills/composite-scanner.test.ts:106
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:36
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:44
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:66
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:81
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:94
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:103
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:118
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:144
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:162
LOW js-dynamic-code-execution src/lib/skills/security-scan.test.ts:28
LOW js-dynamic-code-execution src/lib/skills/url-import.test.ts:178
LOW js-dynamic-code-execution src/lib/skills/url-import.test.ts:191
LOW secret-jwt src/lib/supabase/service.test.ts:16
LOW secret-jwt src/lib/supabase/service.test.ts:40
LOW secret-generic-credential src/lib/validations.test.ts:148
LOW secret-generic-credential src/lib/validations.test.ts:512
LOW secret-generic-credential src/lib/validations.test.ts:523
LOW secret-generic-credential src/lib/validations.test.ts:538
LOW secret-generic-credential src/lib/validations.test.ts:548
LOW secret-generic-credential src/lib/validations.test.ts:557
LOW secret-generic-credential src/lib/validations.test.ts:567
LOW secret-generic-credential src/lib/validations.test.ts:582

Snippets are redacted; ThreatCrush never prints matched credential material.

…be, 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 <noreply@anthropic.com>
@ralyodio
ralyodio force-pushed the feat/application-notifications branch from 216bf47 to 41f1b47 Compare October 6, 2026 10:36
@ralyodio

ralyodio commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto master after #594, #595 and #597.

Checks run by hand: tsc --noEmit is clean, eslint on the changed files shows 0 errors, and the full vitest suite passes (262 files, 2481 tests).

@ralyodio
ralyodio merged commit 0c6dc44 into master Oct 6, 2026
6 checks passed
@ralyodio
ralyodio deleted the feat/application-notifications branch October 6, 2026 10:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant