@clubedge/create-clubedge-app 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +13 -11
  3. package/dist/core/lockfile-peers.js +16 -1
  4. package/package.json +3 -3
  5. package/template/.env.example +17 -0
  6. package/template/README.md +6 -0
  7. package/template/SETUP.md +62 -0
  8. package/template/apps/start/.env.example +17 -0
  9. package/template/apps/start/package.json +5 -1
  10. package/template/apps/start/scripts/check-services.ts +20 -0
  11. package/template/apps/start/src/components/billing/billing-card.tsx +83 -0
  12. package/template/apps/start/src/components/jobs/jobs-card.tsx +71 -0
  13. package/template/apps/start/src/config/plans.ts +16 -0
  14. package/template/apps/start/src/jobs/index.ts +7 -0
  15. package/template/apps/start/src/jobs/sample.ts +10 -0
  16. package/template/apps/start/src/nitro/plugins/jobs.ts +8 -0
  17. package/template/apps/start/src/routes/api/jobs/run.ts +28 -0
  18. package/template/apps/start/src/routes/api/webhooks/payments.ts +31 -0
  19. package/template/apps/start/src/routes/billing/checkout.ts +11 -0
  20. package/template/apps/start/src/routes/billing/portal.ts +11 -0
  21. package/template/apps/start/src/routes/dashboard.tsx +27 -2
  22. package/template/apps/start/src/routes/jobs/sample.ts +11 -0
  23. package/template/apps/start/src/server/billing-forms.ts +50 -0
  24. package/template/apps/start/src/server/billing.functions.ts +29 -0
  25. package/template/apps/start/src/server/jobs-forms.ts +17 -0
  26. package/template/apps/start/src/server/jobs.functions.ts +29 -0
  27. package/template/apps/start/src/server/jobs.ts +59 -0
  28. package/template/apps/start/src/server/payments.ts +60 -0
  29. package/template/apps/start/src/server/users.ts +6 -1
  30. package/template/apps/start/src/start.ts +3 -1
  31. package/template/apps/start/vite.config.ts +2 -1
  32. package/template/apps/web/package.json +5 -1
  33. package/template/apps/web/scripts/check-services.ts +20 -0
  34. package/template/apps/web/src/app/api/jobs/run/route.ts +23 -0
  35. package/template/apps/web/src/app/api/webhooks/payments/route.ts +23 -0
  36. package/template/apps/web/src/app/dashboard/_components/billing-actions.ts +44 -0
  37. package/template/apps/web/src/app/dashboard/_components/billing-card.tsx +89 -0
  38. package/template/apps/web/src/app/dashboard/_components/jobs-actions.ts +14 -0
  39. package/template/apps/web/src/app/dashboard/_components/jobs-card.tsx +83 -0
  40. package/template/apps/web/src/app/dashboard/page.tsx +14 -0
  41. package/template/apps/web/src/config/plans.ts +16 -0
  42. package/template/apps/web/src/instrumentation.ts +8 -0
  43. package/template/apps/web/src/jobs/index.ts +7 -0
  44. package/template/apps/web/src/jobs/sample.ts +10 -0
  45. package/template/apps/web/src/server/jobs.ts +59 -0
  46. package/template/apps/web/src/server/payments.ts +60 -0
  47. package/template/apps/web/src/server/users.ts +6 -1
  48. package/template/clubedge.template.json +68 -0
  49. package/template/e2e/jobs.spec.ts +33 -0
  50. package/template/e2e/payments.spec.ts +51 -0
  51. package/template/e2e/stripe-fake.ts +95 -0
  52. package/template/package.json +1 -1
  53. package/template/packages/auth/package.json +1 -1
  54. package/template/packages/auth-better-auth/package.json +1 -1
  55. package/template/packages/auth-supabase/package.json +1 -1
  56. package/template/packages/cache/package.json +1 -1
  57. package/template/packages/cache-redis/package.json +1 -1
  58. package/template/packages/core/package.json +1 -1
  59. package/template/packages/db/package.json +1 -1
  60. package/template/packages/db/src/index.ts +1 -1
  61. package/template/packages/db/src/users.ts +23 -4
  62. package/template/packages/email/package.json +1 -1
  63. package/template/packages/email-resend/package.json +1 -1
  64. package/template/packages/email-smtp/package.json +1 -1
  65. package/template/packages/jobs/package.json +18 -0
  66. package/template/packages/jobs/src/index.test.ts +69 -0
  67. package/template/packages/jobs/src/index.ts +205 -0
  68. package/template/packages/jobs/tsconfig.json +4 -0
  69. package/template/packages/jobs-pg-boss/package.json +23 -0
  70. package/template/packages/jobs-pg-boss/src/index.test.ts +117 -0
  71. package/template/packages/jobs-pg-boss/src/index.ts +200 -0
  72. package/template/packages/jobs-pg-boss/tsconfig.json +4 -0
  73. package/template/packages/local-db/package.json +1 -1
  74. package/template/packages/payments/drizzle/0000_initial.sql +24 -0
  75. package/template/packages/payments/drizzle/meta/0000_snapshot.json +171 -0
  76. package/template/packages/payments/drizzle/meta/_journal.json +13 -0
  77. package/template/packages/payments/drizzle.config.ts +12 -0
  78. package/template/packages/payments/package.json +35 -0
  79. package/template/packages/payments/scripts/migrate.ts +14 -0
  80. package/template/packages/payments/src/billing.test.ts +151 -0
  81. package/template/packages/payments/src/billing.ts +125 -0
  82. package/template/packages/payments/src/index.ts +17 -0
  83. package/template/packages/payments/src/schema.ts +57 -0
  84. package/template/packages/payments/src/types.ts +67 -0
  85. package/template/packages/payments/tsconfig.json +4 -0
  86. package/template/packages/payments-stripe/package.json +22 -0
  87. package/template/packages/payments-stripe/src/index.test.ts +156 -0
  88. package/template/packages/payments-stripe/src/index.ts +141 -0
  89. package/template/packages/payments-stripe/tsconfig.json +4 -0
  90. package/template/packages/storage/package.json +1 -1
  91. package/template/packages/storage-s3/package.json +1 -1
  92. package/template/packages/storage-supabase/package.json +1 -1
  93. package/template/packages/ui/package.json +1 -1
  94. package/template/pnpm-lock.yaml +345 -55
  95. package/template/render.yaml +8 -0
  96. package/template.lock.json +58 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.13.0
6
+
7
+ - Default to Starter `v0.12.0`, which adds background jobs (`--jobs pg-boss`): jobs stored in the project's PostgreSQL, run by a worker inside the app server, with retries and schedules, and `/api/jobs/run` for serverless hosts. `--jobs` needs no CLI code: it comes from the Starter's manifest.
8
+ - Fix generated lockfiles that pnpm rejected as broken when pruning an optional peer left a package with no dependencies, such as `pg-pool` once `pg` is pruned for `--jobs none`. Such entries are now written as `{}`, the way pnpm writes them.
9
+ - Test a jobs project in CI and in the release smoke test, and add the jobs module in the change-modules job.
10
+
11
+ ## 0.12.0
12
+
13
+ - Default to Starter `v0.11.0`, which adds payments (`--payments stripe`): Stripe Checkout, the customer portal, and verified webhooks behind a provider-neutral payments interface. `--payments` needs no CLI code: it comes from the Starter's manifest.
14
+ - Test a payments project in CI and in the release smoke test, and add the payments module in the change-modules job.
15
+
5
16
  ## 0.11.0
6
17
 
7
18
  - Default to Starter `v0.10.0`, which adds the deploy module (`--deploy docker|fly|railway|render|vercel|netlify`), a migrator in the production image, a manual deploy workflow, and the `/api/ready` readiness endpoint. `--deploy` needs no CLI code: it comes from the Starter's manifest.
package/README.md CHANGED
@@ -12,27 +12,27 @@ The CLI downloads a tested Starter release, names the generated project, creates
12
12
  Each CLI release is tested against one specific Starter release:
13
13
 
14
14
  ```text
15
- create-clubedge-app v0.11.0
15
+ create-clubedge-app v0.13.0
16
16
  |
17
17
  v
18
- Clubedge Starter v0.10.0
19
- commit 9d8fec71b3851c2d615735fb37edb6bb0e568761
18
+ Clubedge Starter v0.12.0
19
+ commit 445add73c3505cd8505faccb3b0b7061a29f5356
20
20
  ```
21
21
 
22
22
  The current relationship is:
23
23
 
24
24
  | CLI release | Default Starter release | Meaning |
25
25
  | --- | --- | --- |
26
- | `create-clubedge-app@0.11.0` | `clubedge-starter@v0.10.0` | The CLI's required, tested default |
26
+ | `create-clubedge-app@0.13.0` | `clubedge-starter@v0.12.0` | The CLI's required, tested default |
27
27
 
28
28
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
29
29
 
30
- The `v0.10.0` tag is expected to resolve to commit
31
- `9d8fec71b3851c2d615735fb37edb6bb0e568761`. The CLI verifies this before
30
+ The `v0.12.0` tag is expected to resolve to commit
31
+ `445add73c3505cd8505faccb3b0b7061a29f5356`. The CLI verifies this before
32
32
  scaffolding with the default reference and fails rather than silently using a
33
33
  moved tag.
34
34
 
35
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.10.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
35
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.12.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
36
36
 
37
37
  ## Requirements
38
38
 
@@ -85,7 +85,7 @@ Either way, the app lives in `apps/web` and uses the same shared packages, so th
85
85
 
86
86
  ### Choosing services
87
87
 
88
- Authentication, file storage, caching, email, local infrastructure, and deployment are modules. Each has a default, and anything you leave out is removed completely: its package, its provider SDK, its environment variables, its documentation, and its lockfile entries. The Starter's `clubedge.template.json` defines the modules, so a module a newer Starter adds gets its `--<module>` flag without a CLI change; `--help` lists the modules of the Starter this CLI release creates.
88
+ Authentication, file storage, caching, email, payments, background jobs, local infrastructure, and deployment are modules. Each has a default, and anything you leave out is removed completely: its package, its provider SDK, its environment variables, its documentation, and its lockfile entries. The Starter's `clubedge.template.json` defines the modules, so a module a newer Starter adds gets its `--<module>` flag without a CLI change; `--help` lists the modules of the Starter this CLI release creates.
89
89
 
90
90
  | Flag | Options | Default |
91
91
  | ----------- | ------------------------------------------ | ---------- |
@@ -93,6 +93,8 @@ Authentication, file storage, caching, email, local infrastructure, and deployme
93
93
  | `--storage` | `s3`, `supabase` (needs Supabase Auth), `none` | `s3` |
94
94
  | `--cache` | `redis` (falls back to memory without `REDIS_URL`), `memory` | `redis` |
95
95
  | `--email` | `smtp` (any provider, or a local inbox), `resend`, `none` | `smtp` |
96
+ | `--payments` | `stripe`, `none` (needs an auth module) | `none` |
97
+ | `--jobs` | `pg-boss`, `none` | `none` |
96
98
  | `--deploy` | `docker` (any container host), `fly`, `railway`, `render`, `vercel`, `netlify` | `docker` |
97
99
  | `--infra` | `docker` (Docker Compose), `supabase` (Supabase CLI), `local` (no Docker: PGlite and a local folder), `none` | `docker` |
98
100
 
@@ -138,7 +140,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
138
140
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
139
141
 
140
142
  # Reproduce a specific Starter release or commit
141
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.10.0
143
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.12.0
142
144
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
143
145
 
144
146
  # Test local Starter changes before releasing them
@@ -155,7 +157,7 @@ pnpm dlx @clubedge/create-clubedge-app add cache redis # or switch: add aut
155
157
  pnpm dlx @clubedge/create-clubedge-app remove storage
156
158
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
157
159
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
158
- pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.10.0 # or a specific tag, branch, or commit
160
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.12.0 # or a specific tag, branch, or commit
159
161
  ```
160
162
 
161
163
  The CLI renders the project twice, from the Starter commit and modules recorded in your `package.json` and from the new state, and merges the difference into your project. The new state has other modules for `add` and `remove`, and the newer Starter with the same modules for `upgrade`. Modules a newer Starter introduces start as `none` where possible, so an upgrade never adds a service on its own.
@@ -206,7 +208,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
206
208
  1. Make and validate changes in `clubedge-starter`.
207
209
  2. Run lint, typecheck, unit tests, browser tests, and production build.
208
210
  3. Update the Starter package versions.
209
- 4. Commit `main`, create a release tag such as `v0.10.0`, and publish the Starter release.
211
+ 4. Commit `main`, create a release tag such as `v0.12.0`, and publish the Starter release.
210
212
 
211
213
  ### CLI maintainers
212
214
 
@@ -170,5 +170,20 @@ export function pruneOptionalPeers(source) {
170
170
  }
171
171
  output.push(line);
172
172
  }
173
- return output.join("\n");
173
+ return closeEmptySnapshots(output).join("\n");
174
+ }
175
+ /**
176
+ * A snapshot whose only dependencies were removed links is left without a body, which pnpm reads
177
+ * as null and rejects as a broken lockfile; pnpm itself writes such entries as "key: {}".
178
+ */
179
+ function closeEmptySnapshots(lines) {
180
+ let section = "";
181
+ return lines.map((line, index) => {
182
+ if (/^\S/.test(line))
183
+ section = line.replace(/:.*$/, "");
184
+ if (section !== "snapshots" || !/^ {2}\S.*:$/.test(line))
185
+ return line;
186
+ const next = lines.slice(index + 1).find((candidate) => candidate.trim() !== "");
187
+ return next === undefined || !/^ {4}/.test(next) ? `${line} {}` : line;
188
+ });
174
189
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Create a Next.js or TanStack Start application from the Clubedge Starter reference implementation.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.9.0",
@@ -22,8 +22,8 @@
22
22
  },
23
23
  "clubedge": {
24
24
  "starterRepository": "Clubedge/clubedge-starter",
25
- "starterRef": "v0.10.0",
26
- "starterCommit": "9d8fec71b3851c2d615735fb37edb6bb0e568761"
25
+ "starterRef": "v0.12.0",
26
+ "starterCommit": "445add73c3505cd8505faccb3b0b7061a29f5356"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "repository": {
@@ -51,6 +51,23 @@ RESEND_API_KEY=
51
51
  EMAIL_FROM=App <no-reply@example.com>
52
52
  # clubedge:end
53
53
 
54
+ # clubedge:if payments=stripe
55
+ # Stripe (https://dashboard.stripe.com/apikeys): use test-mode keys during development. The
56
+ # webhook secret comes from `stripe listen` locally, or from the endpoint's settings in production.
57
+ STRIPE_SECRET_KEY=
58
+ STRIPE_WEBHOOK_SECRET=
59
+ # The Stripe price for each plan in src/config/plans.ts (STRIPE_PRICE_<PLAN>).
60
+ STRIPE_PRICE_PRO=
61
+ # clubedge:end
62
+
63
+ # clubedge:if jobs=pg-boss
64
+ # Background jobs are stored in DATABASE_URL and run by a worker inside the app server. Set
65
+ # JOBS_WORKER=off where no server keeps running (it is off by default on Vercel and Netlify);
66
+ # a scheduler then calls /api/jobs/run with CRON_SECRET (16 or more characters) instead.
67
+ JOBS_WORKER=
68
+ CRON_SECRET=
69
+ # clubedge:end
70
+
54
71
  # clubedge:if storage=s3
55
72
  # S3-compatible storage (AWS S3, Cloudflare R2, MinIO)
56
73
  STORAGE_BUCKET= # clubedge:only infra!=docker
@@ -27,6 +27,8 @@ This repository contains the same application for two frameworks, Next.js (`apps
27
27
  - Fixed-window rate limiter and cache in process memory, suited to a single server instance. <!-- clubedge:only cache=memory -->
28
28
  - Email over SMTP, for any provider or a local inbox; development prints emails to the server log until SMTP is configured. <!-- clubedge:only email=smtp -->
29
29
  - Email through Resend; development prints emails to the server log until the API key is set. <!-- clubedge:only email=resend -->
30
+ - Subscriptions with Stripe Checkout and the customer portal, kept in sync by verified webhooks, behind a provider-neutral payments interface. <!-- clubedge:only payments=stripe -->
31
+ - Background jobs with retries and schedules, stored in PostgreSQL by pg-boss and run by a worker inside the app server, behind a provider-neutral jobs interface. <!-- clubedge:only jobs=pg-boss -->
30
32
  - Zod environment validation, security headers, structured errors, and `/api/health`.
31
33
  - Docker support, GitHub Actions CI, Vitest, Playwright, ESLint, and Prettier.
32
34
  - A production image that includes its own migrator (`node /app/migrate.mjs`), for running migrations before each release.
@@ -221,6 +223,10 @@ The TanStack Start app is `apps/start`, run with `pnpm --filter @clubedge/start
221
223
  - `packages/email/`: `EmailProvider` interface with console and in-memory providers <!-- clubedge:only email!=none -->
222
224
  - `packages/email-smtp/`: SMTP email provider <!-- clubedge:only email=smtp -->
223
225
  - `packages/email-resend/`: Resend email provider <!-- clubedge:only email=resend -->
226
+ - `packages/payments/`: `PaymentProvider` interface, billing logic, and the subscription tables <!-- clubedge:only payments!=none -->
227
+ - `packages/payments-stripe/`: Stripe payment provider <!-- clubedge:only payments=stripe -->
228
+ - `packages/jobs/`: `JobQueue` interface, job definitions, and an in-memory queue for tests <!-- clubedge:only jobs!=none -->
229
+ - `packages/jobs-pg-boss/`: pg-boss job queue <!-- clubedge:only jobs=pg-boss -->
224
230
  - `packages/ui/`: shared shadcn/ui components, utilities, and global theme styles
225
231
  - `.github/`: CI workflow, issue forms, and pull request template
226
232
  - `SETUP.md`, `CONTRIBUTING.md`, `SECURITY.md`: setup, contribution, and security guides
package/template/SETUP.md CHANGED
@@ -45,6 +45,8 @@ The variables are:
45
45
  - `REDIS_URL` (optional): see [Redis](#redis). <!-- clubedge:only cache=redis -->
46
46
  - `SMTP_URL` and `EMAIL_FROM` (for sending email): see [Email](#email). <!-- clubedge:only email=smtp -->
47
47
  - `RESEND_API_KEY` and `EMAIL_FROM` (for sending email): see [Email](#email). <!-- clubedge:only email=resend -->
48
+ - `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, and `STRIPE_PRICE_<PLAN>` (for payments): see [Payments](#payments). <!-- clubedge:only payments=stripe -->
49
+ - `JOBS_WORKER` and `CRON_SECRET` (optional): see [Background jobs](#background-jobs). <!-- clubedge:only jobs=pg-boss -->
48
50
  - `STORAGE_*` (for file storage): see [File storage](#file-storage). <!-- clubedge:only storage=s3 -->
49
51
  - `SUPABASE_STORAGE_BUCKET` (for file storage): see [File storage](#file-storage). <!-- clubedge:only storage=supabase -->
50
52
 
@@ -208,6 +210,60 @@ Set `RESEND_API_KEY` to a [Resend](https://resend.com) API key and `EMAIL_FROM`
208
210
 
209
211
  Without a provider configured, development prints each email to the server log, so you can open links from the terminal, and production refuses to send. `pnpm check:services` confirms that the provider accepts the settings without sending anything.
210
212
 
213
+ <!-- clubedge:end -->
214
+ <!-- clubedge:if payments!=none -->
215
+
216
+ ## Payments
217
+
218
+ The plans users can buy are in `apps/web/src/config/plans.ts`, with the app's own ids. The dashboard's billing card offers checkout for each plan, then shows the subscription and opens billing management. Server code reads a user's plan with `getBilling().subscriptionFor(userId)` and `isEntitled` from `@/server/payments`, and the `subscriptions` table keeps it in sync through the provider's webhook at `/api/webhooks/payments`.
219
+
220
+ <!-- clubedge:end -->
221
+ <!-- clubedge:if payments=stripe -->
222
+
223
+ [Stripe](https://stripe.com) runs checkout and billing management. In test mode:
224
+
225
+ 1. Copy the secret key from the dashboard's API keys into `STRIPE_SECRET_KEY`.
226
+ 2. Create a recurring price for each plan and put its id in `STRIPE_PRICE_<PLAN>`, such as `STRIPE_PRICE_PRO`.
227
+ 3. Forward webhooks to your machine with the [Stripe CLI](https://docs.stripe.com/stripe-cli), and put the signing secret it prints in `STRIPE_WEBHOOK_SECRET`:
228
+
229
+ ```sh
230
+ stripe listen --forward-to localhost:3000/api/webhooks/payments
231
+ ```
232
+
233
+ Turn on the customer portal in the dashboard's billing settings, so "Manage billing" works. In production, add a webhook endpoint for `https://<your-domain>/api/webhooks/payments` with the events `checkout.session.completed` and `customer.subscription.created`, `.updated`, `.deleted`, `.paused`, and `.resumed`, and use its signing secret. Every webhook's signature is verified, each event is applied once, and subscription events read the subscription's current state from Stripe, so retried or out-of-order deliveries are safe.
234
+
235
+ <!-- clubedge:end -->
236
+ <!-- clubedge:if payments!=none -->
237
+
238
+ Payments go through the provider-neutral `PaymentProvider` interface in `packages/payments`. To use another provider, implement that interface in a new package and return it from `apps/web/src/server/payments.ts`; billing logic, the tables, the routes, and the billing card stay the same.
239
+
240
+ <!-- clubedge:end -->
241
+ <!-- clubedge:if jobs=pg-boss -->
242
+
243
+ ## Background jobs
244
+
245
+ Jobs run outside requests, with retries, in a worker that starts inside the app server: `pnpm dev` and the production server both run it, so there is no separate process to deploy. [pg-boss](https://github.com/timgit/pg-boss) stores them in your PostgreSQL, and creates and upgrades its own tables in the `pgboss` schema when the app starts.
246
+
247
+ Define a job in `apps/web/src/jobs` and add it to the list in `apps/web/src/jobs/index.ts`:
248
+
249
+ ```ts
250
+ import { defineJob } from "@clubedge/jobs";
251
+
252
+ export const welcomeEmail = defineJob<{ userId: string }>({
253
+ name: "welcome-email",
254
+ retryLimit: 3,
255
+ async run({ userId }) {
256
+ // Look the user up and send the email.
257
+ },
258
+ });
259
+ ```
260
+
261
+ Queue it from server code with `getJobs().enqueue(welcomeEmail, { userId })`, from `@/server/jobs`. A thrown error fails the attempt and the job is retried (`retryLimit`, `retryDelaySeconds`, `retryBackoff`). An attempt that runs past `timeoutSeconds`, or whose server stops, is retried too, so make jobs safe to run twice. Add `schedule: "0 3 * * *"` (cron syntax) to run a job on a schedule. Restart `pnpm dev` after adding or changing a job. The dashboard's jobs card queues the sample job from `apps/web/src/jobs/sample.ts` and lists its recent runs; remove both when you add your own.
262
+
263
+ Where no server keeps running, as on Vercel and Netlify, there is no worker: `JOBS_WORKER` defaults to `off` there, and you can set it yourself anywhere. Set `CRON_SECRET` to 16 or more random characters and call `GET /api/jobs/run` every minute or so with the header `Authorization: Bearer <CRON_SECRET>`, from Vercel Cron, a Netlify scheduled function, or any scheduler. Each call runs the jobs that are due. Scheduled jobs need a worker.
264
+
265
+ Jobs go through the provider-neutral `JobQueue` interface in `packages/jobs`. To use another queue, implement that interface in a new package and return it from `apps/web/src/server/jobs.ts`; job definitions, the routes, and the jobs card stay the same.
266
+
211
267
  <!-- clubedge:end -->
212
268
  <!-- clubedge:if storage!=none -->
213
269
 
@@ -310,6 +366,8 @@ Render runs `node /app/migrate.mjs` as the pre-deploy command before each deploy
310
366
 
311
367
  Vercel has no step that runs before a release, so the build command applies migrations (`pnpm db:migrate`) against the `DATABASE_URL` of the deployment's environment. Give Preview deployments their own database, or remove the migration step from `buildCommand` and run migrations yourself. Serverless functions open a connection per instance: use your provider's pooled connection URL (for example Supabase's transaction pooler or Neon's pooled endpoint).
312
368
 
369
+ Vercel runs no worker for [background jobs](#background-jobs). Set `CRON_SECRET` and add `"crons": [{ "path": "/api/jobs/run", "schedule": "* * * * *" }]` to `vercel.json`; Vercel Cron sends the secret itself. Cron jobs more frequent than daily need a paid Vercel plan; otherwise call the endpoint from another scheduler. <!-- clubedge:only jobs=pg-boss -->
370
+
313
371
  <!-- clubedge:end -->
314
372
  <!-- clubedge:if deploy=netlify -->
315
373
 
@@ -318,6 +376,8 @@ Vercel has no step that runs before a release, so the build command applies migr
318
376
 
319
377
  Netlify has no step that runs before a release, so the build command applies migrations (`pnpm db:migrate`) against the `DATABASE_URL` of the deploy context. Give deploy previews their own database, or remove the migration step from the build command and run migrations yourself. Serverless functions open a connection per instance: use your provider's pooled connection URL (for example Supabase's transaction pooler or Neon's pooled endpoint).
320
378
 
379
+ Netlify runs no worker for [background jobs](#background-jobs). Set `JOBS_WORKER=off` and `CRON_SECRET`, then call `/api/jobs/run` every minute from a [scheduled function](https://docs.netlify.com/build/functions/scheduled-functions/) or another scheduler, with the header `Authorization: Bearer <CRON_SECRET>`. <!-- clubedge:only jobs=pg-boss -->
380
+
321
381
  <!-- clubedge:end -->
322
382
 
323
383
  `.github/workflows/deploy.yml` deploys on demand: in GitHub, open Actions, then Deploy, then Run workflow.
@@ -341,7 +401,9 @@ Production needs these variables:
341
401
  - `BETTER_AUTH_SECRET`, at least 32 random characters, different from development. <!-- clubedge:only auth=better-auth -->
342
402
  - `SMTP_URL` and `EMAIL_FROM`; without them, production refuses to send email. <!-- clubedge:only email=smtp -->
343
403
  - `RESEND_API_KEY` and `EMAIL_FROM`; without them, production refuses to send email. <!-- clubedge:only email=resend -->
404
+ - `STRIPE_SECRET_KEY` (live mode), `STRIPE_WEBHOOK_SECRET` from the production webhook endpoint, and `STRIPE_PRICE_<PLAN>` for each plan. <!-- clubedge:only payments=stripe -->
344
405
  - `REDIS_URL`, when you run more than one instance. <!-- clubedge:only cache=redis -->
406
+ - `CRON_SECRET`, where no worker runs and a scheduler calls `/api/jobs/run`. <!-- clubedge:only jobs=pg-boss -->
345
407
  - The `STORAGE_*` variables for your S3-compatible bucket. <!-- clubedge:only storage=s3 -->
346
408
  - `SUPABASE_STORAGE_BUCKET`. <!-- clubedge:only storage=supabase -->
347
409
 
@@ -53,6 +53,23 @@ RESEND_API_KEY=
53
53
  EMAIL_FROM=App <no-reply@example.com>
54
54
  # clubedge:end
55
55
 
56
+ # clubedge:if payments=stripe
57
+ # Stripe (https://dashboard.stripe.com/apikeys): use test-mode keys during development. The
58
+ # webhook secret comes from `stripe listen` locally, or from the endpoint's settings in production.
59
+ STRIPE_SECRET_KEY=
60
+ STRIPE_WEBHOOK_SECRET=
61
+ # The Stripe price for each plan in src/config/plans.ts (STRIPE_PRICE_<PLAN>).
62
+ STRIPE_PRICE_PRO=
63
+ # clubedge:end
64
+
65
+ # clubedge:if jobs=pg-boss
66
+ # Background jobs are stored in DATABASE_URL and run by a worker inside the app server. Set
67
+ # JOBS_WORKER=off where no server keeps running (it is off by default on Vercel and Netlify);
68
+ # a scheduler then calls /api/jobs/run with CRON_SECRET (16 or more characters) instead.
69
+ JOBS_WORKER=
70
+ CRON_SECRET=
71
+ # clubedge:end
72
+
56
73
  # clubedge:if storage=s3
57
74
  # S3-compatible storage (AWS S3, Cloudflare R2, MinIO)
58
75
  STORAGE_BUCKET= # clubedge:only infra!=docker
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/start",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -22,6 +22,10 @@
22
22
  "@clubedge/email": "workspace:*",
23
23
  "@clubedge/email-resend": "workspace:*",
24
24
  "@clubedge/email-smtp": "workspace:*",
25
+ "@clubedge/jobs": "workspace:*",
26
+ "@clubedge/jobs-pg-boss": "workspace:*",
27
+ "@clubedge/payments": "workspace:*",
28
+ "@clubedge/payments-stripe": "workspace:*",
25
29
  "@clubedge/storage": "workspace:*",
26
30
  "@clubedge/storage-s3": "workspace:*",
27
31
  "@clubedge/storage-supabase": "workspace:*",
@@ -14,6 +14,9 @@ import { createSmtpEmail } from "@clubedge/email-smtp";
14
14
  // clubedge:if email=resend
15
15
  import { createResendEmail } from "@clubedge/email-resend";
16
16
  // clubedge:end
17
+ // clubedge:if payments=stripe
18
+ import { createStripePayments } from "@clubedge/payments-stripe";
19
+ // clubedge:end
17
20
  // clubedge:if storage=s3 && infra=local
18
21
  import { createFileSystemStorage } from "@clubedge/storage/file-system";
19
22
  // clubedge:end
@@ -100,6 +103,23 @@ async function main() {
100
103
  },
101
104
  },
102
105
  // clubedge:end
106
+ // clubedge:if payments=stripe
107
+ {
108
+ name: "Payments (Stripe)",
109
+ skip: env.STRIPE_SECRET_KEY ? undefined : "STRIPE_SECRET_KEY is not set",
110
+ async run() {
111
+ // A read-only request that checks the key; nothing is charged or created.
112
+ const stripe = createStripePayments({
113
+ secretKey: env.STRIPE_SECRET_KEY!,
114
+ webhookSecret: env.STRIPE_WEBHOOK_SECRET || "unused",
115
+ prices: {},
116
+ apiBase: env.STRIPE_API_BASE || undefined,
117
+ });
118
+ await stripe.verify();
119
+ return "secret key accepted";
120
+ },
121
+ },
122
+ // clubedge:end
103
123
  // clubedge:if storage=s3 && infra=local
104
124
  {
105
125
  name: "Local folder storage",
@@ -0,0 +1,83 @@
1
+ import { CreditCard } from "lucide-react";
2
+
3
+ import { plans } from "@/config/plans";
4
+ import type { BillingStatus } from "@/server/billing.functions";
5
+ import { Badge } from "@clubedge/ui/components/badge";
6
+ import { Button } from "@clubedge/ui/components/button";
7
+ import {
8
+ Card,
9
+ CardContent,
10
+ CardDescription,
11
+ CardHeader,
12
+ CardTitle,
13
+ } from "@clubedge/ui/components/card";
14
+
15
+ const dateFormat = new Intl.DateTimeFormat("en", { dateStyle: "medium", timeZone: "UTC" });
16
+
17
+ /** The signed-in user's plan, with checkout or the billing portal. Forms post to /billing/*. */
18
+ export function BillingCard({ billing }: { billing: BillingStatus }) {
19
+ const { subscription } = billing;
20
+
21
+ return (
22
+ <Card className="gap-0 py-0" id="billing">
23
+ <CardHeader className="flex-row items-start justify-between gap-3 border-b px-5 py-5 sm:px-6">
24
+ <div className="grid gap-1">
25
+ <CardTitle className="text-base">Billing</CardTitle>
26
+ <CardDescription>Your plan and payment details.</CardDescription>
27
+ </div>
28
+ <span className="grid size-9 place-items-center rounded-lg bg-primary/10 text-primary">
29
+ <CreditCard aria-hidden="true" className="size-4" />
30
+ </span>
31
+ </CardHeader>
32
+ <CardContent className="grid gap-4 px-5 py-5 sm:px-6">
33
+ {!billing.configured ? (
34
+ <p className="text-sm text-muted-foreground">
35
+ Payments are not configured yet. Set the Stripe variables in{" "}
36
+ <code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
37
+ apps/web/.env.local
38
+ </code>
39
+ .
40
+ </p>
41
+ ) : !billing.signedIn ? (
42
+ <p className="text-sm text-muted-foreground">Sign in to choose a plan.</p>
43
+ ) : subscription ? (
44
+ <>
45
+ <div className="flex flex-wrap items-center gap-2">
46
+ <span className="font-medium">{subscription.planName}</span>
47
+ <Badge variant={subscription.entitled ? "default" : "secondary"}>
48
+ {subscription.status.replace(/_/g, " ")}
49
+ </Badge>
50
+ </div>
51
+ {subscription.currentPeriodEnd && (
52
+ <p className="text-sm text-muted-foreground">
53
+ {subscription.cancelAtPeriodEnd ? "Ends" : "Renews"} on{" "}
54
+ {dateFormat.format(new Date(subscription.currentPeriodEnd))}.
55
+ </p>
56
+ )}
57
+ <form action="/billing/portal" method="post">
58
+ <Button type="submit" variant="outline">
59
+ Manage billing
60
+ </Button>
61
+ </form>
62
+ </>
63
+ ) : (
64
+ plans.map((plan) => (
65
+ <form
66
+ action="/billing/checkout"
67
+ className="flex flex-wrap items-center justify-between gap-3"
68
+ key={plan.id}
69
+ method="post"
70
+ >
71
+ <input name="planId" type="hidden" value={plan.id} />
72
+ <div className="grid gap-0.5">
73
+ <span className="font-medium">{plan.name}</span>
74
+ <span className="text-sm text-muted-foreground">{plan.description}</span>
75
+ </div>
76
+ <Button type="submit">Upgrade to {plan.name}</Button>
77
+ </form>
78
+ ))
79
+ )}
80
+ </CardContent>
81
+ </Card>
82
+ );
83
+ }
@@ -0,0 +1,71 @@
1
+ import { ListChecks } from "lucide-react";
2
+
3
+ import type { JobsStatus } from "@/server/jobs.functions";
4
+ import { Badge } from "@clubedge/ui/components/badge";
5
+ import { Button } from "@clubedge/ui/components/button";
6
+ import {
7
+ Card,
8
+ CardContent,
9
+ CardDescription,
10
+ CardHeader,
11
+ CardTitle,
12
+ } from "@clubedge/ui/components/card";
13
+
14
+ const timeFormat = new Intl.DateTimeFormat("en", { timeStyle: "medium", timeZone: "UTC" });
15
+
16
+ /** Starts the sample job and lists the current user's recent runs. The form posts to /jobs/sample. */
17
+ export function JobsCard({ status }: { status: JobsStatus }) {
18
+ const { jobs } = status;
19
+
20
+ return (
21
+ <Card className="gap-0 py-0" id="jobs">
22
+ <CardHeader className="flex-row items-start justify-between gap-3 border-b px-5 py-5 sm:px-6">
23
+ <div className="grid gap-1">
24
+ <CardTitle className="text-base">Background jobs</CardTitle>
25
+ <CardDescription>
26
+ {status.worker
27
+ ? "Work runs outside requests in this server's worker, with retries."
28
+ : "This server runs no worker: jobs run when /api/jobs/run is called."}
29
+ </CardDescription>
30
+ </div>
31
+ <span className="grid size-9 place-items-center rounded-lg bg-primary/10 text-primary">
32
+ <ListChecks aria-hidden="true" className="size-4" />
33
+ </span>
34
+ </CardHeader>
35
+ <CardContent className="grid gap-4 px-5 py-5 sm:px-6">
36
+ {jobs === null ? (
37
+ <p className="text-sm text-muted-foreground">
38
+ Jobs are unavailable: the database could not be reached.
39
+ </p>
40
+ ) : (
41
+ <>
42
+ <form action="/jobs/sample" method="post">
43
+ <Button type="submit" variant="outline">
44
+ Run a sample job
45
+ </Button>
46
+ </form>
47
+ {jobs.length === 0 ? (
48
+ <p className="text-sm text-muted-foreground">No jobs yet.</p>
49
+ ) : (
50
+ <ul aria-label="Recent jobs" className="grid gap-2">
51
+ {jobs.map((job) => (
52
+ <li className="flex items-center justify-between gap-3 text-sm" key={job.id}>
53
+ <span>
54
+ {job.name}{" "}
55
+ <span className="text-muted-foreground">
56
+ {timeFormat.format(new Date(job.createdAt))} UTC
57
+ </span>
58
+ </span>
59
+ <Badge variant={job.state === "completed" ? "default" : "secondary"}>
60
+ {job.state}
61
+ </Badge>
62
+ </li>
63
+ ))}
64
+ </ul>
65
+ )}
66
+ </>
67
+ )}
68
+ </CardContent>
69
+ </Card>
70
+ );
71
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The plans users can subscribe to. Ids are the app's own: each payment provider maps them to
3
+ * its prices through environment variables, such as STRIPE_PRICE_PRO for the "pro" plan.
4
+ */
5
+ export const plans = [
6
+ {
7
+ id: "pro",
8
+ name: "Pro",
9
+ description: "Everything in the free plan, plus the paid features you build.",
10
+ },
11
+ ] as const;
12
+
13
+ export type PlanId = (typeof plans)[number]["id"];
14
+
15
+ export const planName = (id: string | null) =>
16
+ plans.find((plan) => plan.id === id)?.name ?? "Unknown plan";
@@ -0,0 +1,7 @@
1
+ import type { JobDefinition } from "@clubedge/jobs";
2
+ import { sampleJob } from "./sample";
3
+
4
+ export { sampleJob };
5
+
6
+ /** Every job the worker runs. Define a job in this folder, then add it here. */
7
+ export const jobs: JobDefinition[] = [sampleJob];
@@ -0,0 +1,10 @@
1
+ import { defineJob } from "@clubedge/jobs";
2
+
3
+ /** An example job that the dashboard can start; replace it with your own. */
4
+ export const sampleJob = defineJob<{ requestedBy: string }>({
5
+ name: "sample",
6
+ async run({ requestedBy }, { attempt }) {
7
+ await new Promise((resolve) => setTimeout(resolve, 500));
8
+ return { message: `Hello, ${requestedBy}, from the jobs worker.`, attempt };
9
+ },
10
+ });
@@ -0,0 +1,8 @@
1
+ import { definePlugin } from "nitro";
2
+ import { getJobs, startJobsWorker } from "@/server/jobs";
3
+
4
+ /** Starts the background jobs worker with the server, and stops it with the server. */
5
+ export default definePlugin((nitroApp) => {
6
+ startJobsWorker();
7
+ nitroApp.hooks.hook("close", () => getJobs().stop());
8
+ });
@@ -0,0 +1,28 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { createFileRoute } from "@tanstack/react-router";
3
+ import { getCronSecret, getJobs } from "@/server/jobs";
4
+
5
+ function isAuthorized(request: Request, secret: string): boolean {
6
+ const given = Buffer.from(request.headers.get("authorization") ?? "");
7
+ const expected = Buffer.from(`Bearer ${secret}`);
8
+ return given.length === expected.length && timingSafeEqual(given, expected);
9
+ }
10
+
11
+ /**
12
+ * Runs the jobs that are due, for hosts without a long-running worker: call it on a schedule
13
+ * with `Authorization: Bearer $CRON_SECRET` (Vercel Cron sends that header itself).
14
+ */
15
+ export const Route = createFileRoute("/api/jobs/run")({
16
+ server: {
17
+ handlers: {
18
+ GET: async ({ request }) => {
19
+ const secret = getCronSecret();
20
+ if (!secret) return Response.json({ error: "Not found" }, { status: 404 });
21
+ if (!isAuthorized(request, secret))
22
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
23
+ const result = await getJobs().runDue();
24
+ return Response.json(result, { headers: { "Cache-Control": "no-store" } });
25
+ },
26
+ },
27
+ },
28
+ });
@@ -0,0 +1,31 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { getBilling, isPaymentsConfigured, WebhookVerificationError } from "@/server/payments";
3
+
4
+ /**
5
+ * The payment provider's webhook. The body is read as raw text, because the signature covers
6
+ * the exact bytes. Each event is applied once; failures answer 500 so the provider retries.
7
+ */
8
+ export const Route = createFileRoute("/api/webhooks/payments")({
9
+ server: {
10
+ handlers: {
11
+ POST: async ({ request }) => {
12
+ if (!isPaymentsConfigured()) {
13
+ return Response.json({ error: "Payments are not configured." }, { status: 503 });
14
+ }
15
+ try {
16
+ const result = await getBilling().handleWebhook({
17
+ body: await request.text(),
18
+ headers: request.headers,
19
+ });
20
+ return Response.json({ received: true, result });
21
+ } catch (error) {
22
+ if (error instanceof WebhookVerificationError) {
23
+ return Response.json({ error: "Invalid signature." }, { status: 400 });
24
+ }
25
+ console.error("Payment webhook failed", error);
26
+ return Response.json({ error: "Webhook failed." }, { status: 500 });
27
+ }
28
+ },
29
+ },
30
+ },
31
+ });
@@ -0,0 +1,11 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { handleCheckout } from "@/server/billing-forms";
3
+
4
+ export const Route = createFileRoute("/billing/checkout")({
5
+ server: {
6
+ handlers: {
7
+ GET: () => new Response(null, { status: 303, headers: { Location: "/dashboard" } }),
8
+ POST: ({ request }) => handleCheckout(request),
9
+ },
10
+ },
11
+ });