Skip to content

feat(gigs): expiry + renew, for-hire ad caps, application caps and spam hold - #597

Merged
ralyodio merged 1 commit into
masterfrom
feat/gig-expiry-and-caps
Oct 6, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feat/gig-expiry-and-caps

Conversation

@ralyodio

@ralyodio ralyodio commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

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

  • New column gigs.expires_at. A hiring gig expires 30 days after it goes active and a for_hire ad after 60. The app sets it on POST /api/gigs, on PATCH /api/gigs/[id]/status and on PUT /api/gigs/[id] whenever a gig becomes active. Re-activating through the status route resets it. A BEFORE trigger fills it for any path that skips the app.
  • The migration backfills active gigs to 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-gigs authenticates with CRON_SECRET (the x-cron-secret or Authorization: Bearer header) and accepts ?dry_run=1. It pauses up to 200 active gigs per run that are past expires_at.
    • The pause is a guarded update (status = active AND expires_at < now), so two overlapping runs can't both pause the same gig and email its poster twice.
    • It emails the poster once using the previously unused gigExpiredEmail, gated on the email_gig_updates setting.
  • "Paused by expiry" means 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 moves expires_at to now + 30 days (60 for ads).
    • It works on active and paused gigs. It returns 409 for draft, closed and filled gigs.
    • Re-listing a paused ad goes through the ad caps.
    • It's available from a "Renew for N days" button on /dashboard/gigs and on the gig owner card (anchor #renew, which the email links to), from ugig 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.
  • Expired gigs are paused, and /gigs, /for-hire (fetchGigs), GET /api/gigs, search and the sitemap all filter on status = active. Tests now assert this for everything except the sitemap, which has no test file.

For-hire ad caps

Rule Response
At most 10 new for_hire ads per account in any rolling 24h (drafts count) 429, with Retry-After set to when the oldest ad in the window ages out
At most 50 active for_hire ads per account 429
Title equal, after normalizing case, digits and punctuation, to one of the account's last 50 ad titles 409

These are enforced on POST /api/gigs, the status route, PUT (on activation, a change to for_hire, or a title change on a live ad) and renew.

Application caps

  • At most 50 applications per applicant in any rolling 24h. Agent accounts younger than 7 days get 20. Over the cap returns 429 with Retry-After.
  • A cover letter that is identical, after normalizing whitespace and case, to any of the applicant's last 20 is rejected with 409 and a "tailor it" message. A resubmitted withdrawn application doesn't count against itself.
  • Applications from profiles with is_spam=true are accepted but held: they're stored pending with metadata.held = 'spam_review', and the poster never sees them.
    • The poster gets no email, webhook or in-app notification. notify_on_new_application (redefined from prod's pg_get_functiondef) skips held applications.
    • Held applications are not counted in applications_count.
    • They're excluded from GET /api/gigs/[id]/applications, /gigs/[id]/applications, the dashboard's recent and pending lists, approve-all, message-all and broadcast audiences.
    • A BEFORE INSERT trigger also marks held applications that are inserted directly.
  • These are enforced in POST /api/applications and POST /api/gigs/[id]/applications. /api/gigs/[id]/apply is an alias of the latter.

Signup and src/lib/spam-check.ts are untouched.

Migration (not applied)

  • supabase/migrations/20261006134000_gig_expiry_and_held_applications.sql
    • Idempotent: IF NOT EXISTS, CREATE OR REPLACE, DROP TRIGGER IF EXISTS, and the backfill only touches rows where expires_at is null.
    • Adds gigs.expires_at and three indexes.
    • Backfills active gigs.
    • Adds the set_gig_expires_at and hold_spam_application triggers.
    • Redefines notify_on_new_application, increment_application_count and decrement_application_count so held applications are skipped.

Cron to add (after the migration is applied)

POST https://ugig.net/api/cron/expire-gigs with x-cron-secret, hourly, for example 15 * * * *. Run it with ?dry_run=1 first to see the list. It's also added to scripts/monitor.ts.

Checks (run by hand; commit made with --no-verify)

  • npx tsc --noEmit: clean, for both the root project and cli/
  • npx eslint on changed files: 0 errors. There is 1 warning, an unused eslint-disable directive in the existing applications route test.
  • Full suite (NODE_OPTIONS=--max-old-space-size=2048 npx vitest run --no-file-parallelism): 239 files passed. CLI vitest: 29 files, 194 tests passed.
  • New tests cover every rule at its boundary: 9 vs 10 ads per day, 49 vs 50 active ads, 49 vs 50 applications, 19 vs 20 for new agents, the 7-day agent age boundary, title and letter normalization, Retry-After, the held path, dry run, the email gate, and the guarded pause.

Not done

  • These caps don't stop direct PostgREST inserts made with a user JWT. Only the spam hold is enforced in the database. RLS-level caps would need their own migration.
  • profiles.is_spam is UPDATE-granted to authenticated. 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.
  • Renew does not count against the free-tier gig_usage cap. That cap is still broken (PRD 03 req 1) and is a separate pricing decision.
  • The other emails in src/lib/email.ts still link to the dead /gig/<id> path. Only gigExpiredEmail was fixed here.
  • The existing flood ads are not cleaned up. They'll expire once their backfilled dates pass (at least 14 days out).

🤖 Generated with Claude Code

…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>
@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.

@ralyodio
ralyodio merged commit 2aee410 into master Oct 6, 2026
7 checks passed
@ralyodio
ralyodio deleted the feat/gig-expiry-and-caps branch October 6, 2026 10:31
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