Repository navigation
feat(gigs): expiry + renew, for-hire ad caps, application caps and spam hold - #597
Merged
Merged
Conversation
…am hold PRD 01 req 3, PRD 02 req 5, PRD 03 reqs 2-3. Expiry - gigs.expires_at: hiring gigs expire 30 days after going active, for_hire ads 60. Set on POST /api/gigs, the status route and PUT when a gig becomes active; a BEFORE trigger fills it for any other path. - Migration backfills active gigs to greatest(now()+14d, last boost or creation + 30/60d), so nothing expires the day this ships. - POST /api/cron/expire-gigs (CRON_SECRET, ?dry_run=1) pauses active gigs past expires_at with a guarded update and emails the poster once via the previously unused gigExpiredEmail, gated on email_gig_updates. - POST /api/gigs/[id]/renew (owner only) re-lists for 30/60 days. "Renew for N days" button on /dashboard/gigs and the gig owner card (#renew, linked from the email); `ugig gigs renew <id>` in the CLI. - gigExpiredEmail: fixed the dead /gig/ link, escaped HTML, renew link. For-hire ad caps (src/lib/limits.ts) - 10 new ads per account per rolling 24h (429 + Retry-After), 50 active ads per account (429), and a title equal after case/digit/punctuation normalization to one of the account's last 50 ad titles (409). Enforced on POST /api/gigs, the status route, PUT, and renew. Application caps - 50 applications per rolling 24h; agent accounts under 7 days 20 (429 + Retry-After). A cover letter identical after whitespace/case normalization to one of the applicant's last 20 is refused (409). - Applications from is_spam profiles are stored pending with metadata.held='spam_review': no poster email, webhook or notification (notify_on_new_application skips them), not counted in applications_count, and left out of the poster's lists, approve-all, message-all and broadcast audiences. - Enforced in POST /api/applications and POST /api/gigs/[id]/applications (which /apply aliases). Migration: 20261006134000_gig_expiry_and_held_applications.sql (not applied). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ThreatCrush Security Scan47 finding(s) HIGH/CRITICAL: 1 | MEDIUM: 8 | LOW: 38
Snippets are redacted; ThreatCrush never prints matched credential material. |
This was referenced Oct 6, 2026
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 01 req 3 (expiry), PRD 03 reqs 2-3 (ad caps, ad expiry), and PRD 02 req 5 (application caps), using the decided values. All limits are named constants in
src/lib/limits.ts, and every count is read from the database.Gig expiry
gigs.expires_at. A hiring gig expires 30 days after it goes active and afor_hiread after 60. The app sets it onPOST /api/gigs, onPATCH /api/gigs/[id]/statusand onPUT /api/gigs/[id]whenever a gig becomes active. Re-activating through the status route resets it. ABEFOREtrigger fills it for any path that skips the app.greatest(now() + 14 days, coalesce(boosted_at, created_at) + 30/60 days), so no gig expires on the day this ships.POST /api/cron/expire-gigsauthenticates withCRON_SECRET(thex-cron-secretorAuthorization: Bearerheader) and accepts?dry_run=1. It pauses up to 200 active gigs per run that are pastexpires_at.status = active AND expires_at < now), so two overlapping runs can't both pause the same gig and email its poster twice.gigExpiredEmail, gated on theemail_gig_updatessetting.status = paused AND expires_at <= now(), so no extra flag is needed.POST /api/gigs/[id]/renew(owner only) sets the gig active again and movesexpires_atto now + 30 days (60 for ads)./dashboard/gigsand on the gig owner card (anchor#renew, which the email links to), fromugig gigs renew <id>, and in the OpenAPI spec and API docs.gigExpiredEmail: fixed the dead/gig/<id>link, escaped the title and name in the HTML, and replaced "Post new gig" with the renew link./gigs,/for-hire(fetchGigs),GET /api/gigs, search and the sitemap all filter onstatus = active. Tests now assert this for everything except the sitemap, which has no test file.For-hire ad caps
for_hireads per account in any rolling 24h (drafts count)Retry-Afterset to when the oldest ad in the window ages outfor_hireads per accountThese are enforced on
POST /api/gigs, the status route,PUT(on activation, a change tofor_hire, or a title change on a live ad) and renew.Application caps
Retry-After.is_spam=trueare accepted but held: they're storedpendingwithmetadata.held = 'spam_review', and the poster never sees them.notify_on_new_application(redefined from prod'spg_get_functiondef) skips held applications.applications_count.GET /api/gigs/[id]/applications,/gigs/[id]/applications, the dashboard's recent and pending lists, approve-all, message-all and broadcast audiences.BEFORE INSERTtrigger also marks held applications that are inserted directly.POST /api/applicationsandPOST /api/gigs/[id]/applications./api/gigs/[id]/applyis an alias of the latter.Signup and
src/lib/spam-check.tsare untouched.Migration (not applied)
supabase/migrations/20261006134000_gig_expiry_and_held_applications.sqlIF NOT EXISTS,CREATE OR REPLACE,DROP TRIGGER IF EXISTS, and the backfill only touches rows whereexpires_atis null.gigs.expires_atand three indexes.set_gig_expires_atandhold_spam_applicationtriggers.notify_on_new_application,increment_application_countanddecrement_application_countso held applications are skipped.Cron to add (after the migration is applied)
POST https://ugig.net/api/cron/expire-gigswithx-cron-secret, hourly, for example15 * * * *. Run it with?dry_run=1first to see the list. It's also added toscripts/monitor.ts.Checks (run by hand; commit made with --no-verify)
npx tsc --noEmit: clean, for both the root project andcli/npx eslinton changed files: 0 errors. There is 1 warning, an unused eslint-disable directive in the existing applications route test.NODE_OPTIONS=--max-old-space-size=2048 npx vitest run --no-file-parallelism): 239 files passed. CLI vitest: 29 files, 194 tests passed.Not done
profiles.is_spamis UPDATE-granted toauthenticated. If RLS lets users update their own row, a flagged user could clear the flag. That's out of scope here (no spam-check changes) but worth checking.gig_usagecap. That cap is still broken (PRD 03 req 1) and is a separate pricing decision.src/lib/email.tsstill link to the dead/gig/<id>path. OnlygigExpiredEmailwas fixed here.🤖 Generated with Claude Code