diff --git a/.agents/skills/jobs-define-job/SKILL.md b/.agents/skills/jobs-define-job/SKILL.md new file mode 100644 index 00000000000..36388828d89 --- /dev/null +++ b/.agents/skills/jobs-define-job/SKILL.md @@ -0,0 +1,130 @@ +--- +name: jobs-define-job +description: Add or migrate background jobs with defineJob, register them in jobLoaders, and dispatch via job.dispatch/dispatchBatch instead of qstash.publishJSON to a cron URL. Use when adding a QStash worker, converting a POST /api/cron route into a job, or when the user mentions defineJob, job handlers, /api/jobs/process, or background jobs. +--- + +# Background jobs: use defineJob + +Payload-driven QStash work goes through `defineJob`. Do **not** add a new HTTP route under `/api/jobs` — every job is executed by the existing [`apps/web/app/api/jobs/process/[jobName]/route.ts`](apps/web/app/api/jobs/process/[jobName]/route.ts). + +Keep using `withCron` for Vercel GET schedules and for cron scanners that have no job envelope. See the `cron-use-with-cron` skill for those. + +## File layout + +``` +apps/web/lib/jobs/ +├── index.ts # defineJob — do not edit unless changing the framework +├── registry.ts # jobLoaders — register every new job here +├── send-jobs.ts # envelope + QStash request builder +└── handlers/ + └── {name}-job.ts # one file per job +``` + +## 1. Create the handler + +Add `apps/web/lib/jobs/handlers/{name}-job.ts`. The `name` must be kebab-case and end in `-job` (enforced by `jobNameSchema`: `/^[a-z][a-z0-9]*(-[a-z0-9]+)*-job$/`). + +```ts +import * as z from "zod/v4"; +import { defineJob } from "../index"; + +const inputSchema = z.object({ + programId: z.string(), + partnerId: z.string(), +}); + +export const unbanPartnerJob = defineJob({ + name: "unban-partner-job", + schema: inputSchema, + defaults: { + retries: 3, // optional; QStash retries on 5xx + // queue: "unban-partner", // optional; named QStash queue + // flowControl: { key: "unban-partner", parallelism: 20 }, + }, + async handle(input) { + // skip (permanent / not found) → return (process route returns 2xx, QStash does not retry) + // transient failure → throw (process route returns 500, QStash retries) + }, +}); +``` + +Export the const as camelCase `{name}Job` matching the kebab `name`. + +Reference handlers: + +- Simple skip/work: `unban-partner-job.ts`, `create-tremendous-campaign-job.ts` +- Self-pagination: `folder-deleted-job.ts`, `domain-deleted-job.ts`, `partner-search-sync-job.ts` +- `defaults.flowControl`: `partner-search-sync-job.ts` + +## 2. Register it + +Add a **static** `import()` in [`apps/web/lib/jobs/registry.ts`](apps/web/lib/jobs/registry.ts) `jobLoaders`. The object key must equal `defineJob({ name })`. Webpack code-splits each handler. + +```ts +"unban-partner-job": () => + import("./handlers/unban-partner-job").then((m) => m.unbanPartnerJob), +``` + +Do not register by editing the process route. `loadJob` throws if `job.name !==` the registry key. + +## 3. Dispatch from call sites + +Import the handler (not `qstash`) and call `dispatch` / `dispatchBatch`: + +```ts +import { unbanPartnerJob } from "@/lib/jobs/handlers/unban-partner-job"; + +await unbanPartnerJob.dispatch( + { workspaceId, programId, partnerId }, + { label: partnerId }, +); + +await folderDeletedJob.dispatchBatch( + folderIds.map((folderId) => ({ folderId })), + ({ folderId }) => ({ label: folderId }), +); +``` + +Per-dispatch options merge over `defaults`: `delay`, `notBefore`, `deduplicationId`, `retries`, `queue`, `flowControl`, `label`. + +Failed QStash publish is persisted to the jobs outbox automatically — do not catch-and-swallow unless even the outbox persist failing must not fail the source mutation (see `queue-partner-search-sync.ts`). + +Self-pagination: call `theJob.dispatch(nextPayload, { delay: 1 })` from `handle` (see `folder-deleted-job`, `partner-search-sync-job`). + +Do not call `job.execute` from app code. That is only for the process route (and cron drain shims below). + +## 4. Convert an existing cron worker + +1. Move the `withCron` body into `handle`. Replace `logAndRespond("skip…")` with `console.info` / `console.error` + `return`. +2. Register + switch every `qstash.publishJSON` / `enqueueJSON` / `enqueueBatchJobs` targeting that cron URL to `job.dispatch` / `dispatchBatch`. +3. Keep a thin POST shim at the old `/api/cron/...` URL that parses the **old** body (not the job envelope) and calls `job.execute(payload)`. That drains in-flight QStash messages. Do not add a shim for brand-new jobs. +4. Delete cron-only helpers that moved with the handler. + +## Handle semantics + +| Outcome | What to do | HTTP from process route | QStash | +| --- | --- | --- | --- | +| Work done | `return` | 200 | stop | +| Skip (not found, already done, env not configured) | `console.*` + `return` | 200 | stop | +| Bad payload | throw `ZodError` (schema.parse) | 200 | stop (non-retryable) | +| Transient failure | `throw` | 500 | retry | + +Unknown job names and invalid envelopes also return 2xx so QStash does not retry forever. + +## Do not use defineJob for + +- Vercel GET crons in `apps/web/vercel.json` +- Scanners that only fan out work (the worker they enqueue can be a job) +- Importers that republish continuation state to the same URL +- `/api/cron/queue/retry` (job replay infrastructure) +- Path-param identity (`/api/cron/links/[linkId]/…`) unless the id moves into the payload +- Outbound webhook forwarding and postbacks + +## Do not + +- Add a new `/api/jobs/...` route or a new `/api/cron/...` POST worker for payload-driven work. +- Call `qstash.publishJSON` / `enqueueJSON` / `enqueueBatchJobs` with `/api/jobs/process/...` — use `dispatch`. +- Register jobs with a dynamic `import()` that webpack cannot statically analyze, or a key that differs from `defineJob({ name })`. +- Name a job without the `-job` suffix. +- Use `job.execute` at dispatch call sites. +- Run `pnpm build` after adding a job. diff --git a/.agents/skills/playwright-api-tests/SKILL.md b/.agents/skills/playwright-api-tests/SKILL.md index 63f6005cc5e..d21668ff3c7 100644 --- a/.agents/skills/playwright-api-tests/SKILL.md +++ b/.agents/skills/playwright-api-tests/SKILL.md @@ -54,10 +54,6 @@ import { expect } from "@playwright/test"; import { randomName } from "../../utils"; import { test, type ApiClient } from "../fixtures"; -test.describe.configure({ - mode: "parallel", -}); - async function createThing( api: ApiClient, overrides: Record = {}, @@ -98,7 +94,7 @@ test("POST /things", async ({ api }) => { | Rule | Detail | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | Import `test` from `../fixtures` | Provides `api`, `workspace`, and `program` | -| `test.describe.configure({ mode: "parallel" })` | At top of every API spec file | +| Serial only when tests share state | API project is `fullyParallel: true`. Do not add `mode: "parallel"`. Use `test.describe.configure({ mode: "serial" })` only when tests in a file/describe share state (e.g. domains, seeded pagination) | | Cleanup in `finally` | Create → assert → always delete created rows | | Unique names/ids | Use `randomName` / `randomCustomer` / `randomPartnerEmail` from `../../utils` — never fixed colliding names | | Assert status + body | Prefer `toStrictEqual` / `toEqual` on full shapes; use `expect.any(String)` for ids/timestamps | diff --git a/.gitignore b/.gitignore index 898bef49661..64759ecd647 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,5 @@ packages/stripe-app/.build/* playwright-report/ **/playwright/.auth/ test-results/ -blob-report/ \ No newline at end of file +blob-report/ +.pnpm-store diff --git a/apps/web/.env.example b/apps/web/.env.example index b43adfd90b6..5c26914520c 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -5,8 +5,12 @@ # Generate secrets with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" NEXTAUTH_SECRET= NEXTAUTH_URL=http://localhost:8888 # (only needed for localhost) -# Secret for Vercel cron jobs (https://vercel.com/docs/cron-jobs/manage-cron-jobs#securing-cron-jobs) + +# Secret for Vercel cron jobs + sync-embeddings CRON_SECRET= +# Shared with the LoopWork demo. Mints geo-accurate clicks via POST /api/demo/click +# and backdated commissions via POST /api/demo/commission +DEMO_CLICK_SECRET= # Encryption key (AES-256-GCM) for encrypting sensitive data in the database ENCRYPTION_KEY= # Email unsubscribe token secret (optional, falls back to NEXTAUTH_SECRET) @@ -23,6 +27,10 @@ PLANETSCALE_DATABASE_URL="http://root:unused@localhost:3900/planetscale" UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_TOKEN= +# Full-text partner search. Unset falls back to the database search path. +TURBOPUFFER_API_KEY= +PARTNER_SEARCH_READ_ENABLED= + # Upstash QStash – required for queues and background jobs # Get your QStash Token here: https://upstash.com/docs/qstash/overall/getstarted QSTASH_URL="https://qstash-us-east-1.upstash.io" @@ -108,6 +116,9 @@ STORAGE_BASE_URL= STORAGE_PUBLIC_BUCKET= STORAGE_PRIVATE_BUCKET= +# Shared secret for Cloudflare Workers that call Next.js webhooks +CLOUDFLARE_WORKER_WEBHOOK_SECRET= + # Used for internal monitoring & paging # You can remove this by removing `DUB_SLACK_HOOK_CRON` and `DUB_SLACK_HOOK_LINKS` from the codebase DUB_SLACK_HOOK_CRON= @@ -121,6 +132,7 @@ DUB_SLACK_ASSISTANT_BOT_TOKEN= NEXT_PUBLIC_NGROK_URL= # For AI features +AI_GATEWAY_API_KEY= ANTHROPIC_API_KEY= # Axiom – used for logging and monitoring @@ -195,4 +207,9 @@ E2E_PARTNER_PASSWORD= # Veriff (Identity Verification) VERIFF_API_KEY= -VERIFF_SHARED_SECRET= \ No newline at end of file +VERIFF_SHARED_SECRET= + +# Domain Connect (auto-configure DNS for custom domains) +# Generate key: openssl genrsa -out private.pem 2048 +# Get public key: openssl rsa -in private.pem -pubout -outform DER | base64 | tr -d '\n' +DOMAIN_CONNECT_PRIVATE_KEY= diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx index c29c7c299e6..e59618b380f 100644 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx @@ -43,6 +43,19 @@ function CommissionsPageClient() { const { queryParams, getQueryString, searchParamsObj } = useRouterStuff(); const { interval, start, end, programId } = searchParamsObj; + const { data: { programs: allPrograms } = {} } = useSWR( + `/api/admin/commissions${getQueryString( + { + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + { exclude: ["programId"] }, + )}`, + fetcher, + { + keepPreviousData: true, + }, + ); + const { data: { programs, timeseries } = {}, isLoading } = useSWR( `/api/admin/commissions${getQueryString({ @@ -62,7 +75,7 @@ function CommissionsPageClient() { icon: GridIcon, label: "Program", options: - programs?.map((program) => ({ + allPrograms?.map((program) => ({ value: program.id, label: program.name, icon: ( @@ -72,6 +85,7 @@ function CommissionsPageClient() { className="size-4 rounded-full" /> ), + right: currencyFormatter(program.commissions), })) ?? null, }, ], @@ -279,8 +293,8 @@ function CommissionsPageClient() { }); return ( -
-
+
+
{activeFilters.length > 0 && ( -
- -
+ )} -
-
+
+
{tabs.map(({ id, label, colorClassName, disabled }) => { return (