Repository navigation
Application notifications: status emails, poster digest, one-click unsubscribe, resolve on close/fill/delete - #598
Merged
Conversation
ThreatCrush Security Scan47 finding(s) HIGH/CRITICAL: 1 | MEDIUM: 8 | LOW: 38
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
force-pushed
the
feat/application-notifications
branch
from
October 6, 2026 10:36
216bf47 to
41f1b47
Compare
Contributor
Author
|
Rebased onto master after #594, #595 and #597.
Checks run by hand: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements PRD 02 (application triage) items 1, 2, 6 and part of 3, plus PRD 08 unsubscribe and settings gating.
Changes
Applicant status email.
applicationStatusEmailis now sent when an application becomes accepted, rejected or shortlisted. It goes out fromPUT /api/applications/[id]/status,PUT /api/applications/bulk-statusandPOST /api/gigs/[id]/applications/approve-all. It is gated onemail_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 innewApplicationEmail) 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:POST /api/cron/application-digest. It uses CRON_SECRET auth like daily-stats, and?dry_run=1returns the per-poster plan without sending.applications.metadata.poster_emailed(instantordigest), so a re-run sends nothing new. No migration is needed.applicationDigestEmail.One-click unsubscribe.
src/lib/email-unsubscribe.tscreates and verifies HMAC tokens. The secret isUNSUBSCRIBE_SECRET, falling back toCRON_SECRET. Tokens do not expire.sendEmail({..., unsubscribe: { userId, setting } })adds a footer link plusList-UnsubscribeandList-Unsubscribe-Post: List-Unsubscribe=One-Clickheaders.@profullstack/emailerpassesheadersthrough to Resend.GET/POST /api/email/unsubscribe?token=turns off that one setting and shows a plain confirmation page.Close, fill or delete resolves applications.
PATCH /api/gigs/[id]/statusto closed or filled rejects every pending, reviewing or shortlisted application, withmetadata.reasongig_closedorgig_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 includesresolved_applications.DELETE /api/gigs/[id]returns 409 when the gig has a hired application or an unpaidsentinvoice. Otherwise each open applicant gets one in-appapplication_statusnotice 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.tsis a dry run by default;--applywrites. It rejects open applications on closed or filled gigs with reasongig_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.
/dashboard/applications, usingDELETE /api/applications/[id]./gigs/[id]/applications, callingPUT /api/applications/bulk-statusin batches of 50.Ops
POST https://ugig.net/api/cron/application-digestwithx-cron-secret, once a day. For example0 16 * * *(UTC), next to the other ugig crontab entries.UNSUBSCRIBE_SECRET. Without it, links are signed withCRON_SECRET. Rotating whichever secret signs the links breaks existing links.Not done
PUT /api/gigs/[id]can also setstatus. Only the status route resolves applications.created_at, so if it was not the instant email it misses the digest window.profile-remindersand other non-setting emails get no "unsubscribe from all" yet (PRD 08 item 3, second half).Checks
Run by hand because the pre-commit hook is broken in fresh worktrees:
npx tsc --noEmitis clean, eslint on the changed files shows 0 errors, and the fullvitest run --no-file-parallelismpasses (243 files, 2302 tests). New tests cover every changed route, the cron and unsubscribe routes, the libraries and both components.🤖 Generated with Claude Code