Climate tech job board with paid listings and digests
Job board where companies pay via Stripe for 30-day listings, with weekly filter-matched email digests for seekers, built on Astro SSR and Cloudflare Workers.
astrocloudflare-workersd1stripetypescriptvitest
Built 1 time, downloaded 0 times.
Install from your terminal
Run this in your project folder. It signs you in, shows what changes and asks before it overwrites a file.
npx claude-lab add climate-tech-job-board-with-paid-listings-and-digests-e88570CLAUDE.md# Climate tech job board
## Purpose
- Climate tech job board. Companies pay via Stripe to post a listing.
- Listings expire 30 days after publish.
- Job seekers can save filters. A weekly email digest lists new roles that match.
- No scraping. All posts come from companies.
## Stack
- Astro in SSR mode with the `@astrojs/cloudflare` adapter.
- Cloudflare Workers, with D1 for data.
- Cron Triggers for listing expiry and the weekly digest.
- Queues or the email provider API for sending mail.
- Stripe Checkout plus a webhook.
- TypeScript strict.
## Commands
- `npm run dev`: local dev server.
- `npm run check`: astro check.
- `npm run lint`: eslint and prettier checks.
- `npm test`: vitest.
- `npm run build`: production build.
- `npx wrangler d1 migrations apply DB --local`: local D1 migrations.
- `npx wrangler dev`: Worker runtime locally.
## Layout
- `src/pages` and `src/pages/api`: routes and endpoints.
- `src/lib/db`: typed D1 query helpers.
- `src/lib/stripe`: checkout and webhook handling.
- `src/lib/digest`: digest building and sending.
- `src/lib/filters`: pure filter match function.
- `migrations/`: numbered SQL files.
- `wrangler.toml`: Worker, D1 and cron config.
- `tests/`: vitest suites and Stripe fixtures.
## Data rules
- Listing status is one of: draft, paid, live, expired.
- Only the Stripe webhook may move a listing from draft to live.
- `expires_at` = `published_at` + 30 days.
- Expired listings return 410 and are excluded from the sitemap and the digest.
- Every public query filters `status = 'live' AND expires_at > now`.
## Digest rules
- One email per subscriber per week.
- Include only listings published since `last_digest_at`.
- Skip the email if nothing matches.
- Every email has a one-click unsubscribe link with a signed token.
- Digest sends are idempotent: record sent rows before or with the send.
## Gotchas
- Workers has no Node `fs`. Use the Web Crypto API for signing and for verifying Stripe webhook signatures.
- D1 is async. Batch related writes with `db.batch`.
- The cron handler must be safe to re-run.
- Never trust client-side price or status.
- Sanitize company-submitted descriptions. Allow only a markdown subset.
- Use `.bind()` for every SQL parameter.
## Secrets
- Keep secrets only in `.dev.vars` (gitignored) and via `wrangler secret put`.
- Placeholders: `YOUR_STRIPE_SECRET_KEY`, `YOUR_STRIPE_WEBHOOK_SECRET`, `YOUR_EMAIL_API_KEY`.
- Never commit or print secret values.
## Conventions
- Test mode only for Stripe. No live-mode calls.
- Add tests for new logic, especially filter matching, expiry and digest.
- Keep imports relative inside `src/`.
## Workflow
- Explore, plan, code, then run the verify skill before saying a task is done.
- Use `/clear` between unrelated tasks.
- Ask before running `wrangler deploy`, `wrangler d1 execute --remote`, or any Stripe live-mode call.
## Reference
@REVIEW.md
@SPEC.md
.claude/agents/test-runner.md---
name: test-runner
description: Use to run the test suite (or one test file) and report only the failures. Use instead of running npm test in the main session when output may be long.
model: haiku
tools: Bash, Read
maxTurns: 8
---
You run the project's tests and report failures. You never edit files.
## Steps
1. Run `npm test` from the repo root. If the caller names a file or pattern, run `npx vitest run <file-or-pattern>` instead.
2. If a test fails, read only the test file and the source file it most likely covers, to name the probable cause. Do not read unrelated files.
3. Stop after one rerun if the output is unchanged.
## Report format
For each failing test:
- Test name (full describe and it path)
- Assertion message, trimmed to the lines that matter
- Likely file and line
If everything passes, reply with one line: `All tests passed (N files, M tests).` Do not include passing output.
If the command itself fails to start (missing dependency, config error), report the error message and the command you ran. Do not try to fix the environment.
## Rules
- Never edit, create or delete files.
- Never run deploy, remote, or install commands.
- Never print values from .dev.vars or .env files.
.claude/settings.json{
"model": "sonnet",
"env": {
"CLAUDE_CODE_SUBAGENT_MODEL": "haiku"
},
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(npm test*)",
"Bash(npx astro check*)",
"Bash(npx vitest*)",
"Bash(npx wrangler d1 migrations apply * --local)",
"Bash(npx wrangler dev*)",
"Bash(git status)",
"Bash(git diff*)",
"Bash(git log*)"
],
"ask": [
"Bash(git push*)",
"Bash(npm install*)",
"Bash(npx wrangler deploy*)",
"Bash(npx wrangler secret*)"
],
"deny": [
"Read(./.dev.vars)",
"Read(./.env*)",
"Bash(rm -rf*)",
"Bash(npx wrangler d1 execute * --remote*)"
],
"blockWritesOutsideWorkingDirectories": true
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const f=JSON.parse(d).tool_input.file_path;require('child_process').execFileSync('npx',['--no-install','prettier','--write',f],{stdio:'ignore'})}catch(e){}})\""
}
]
}
]
}
}
.claude/skills/new-migration/SKILL.md---
name: new-migration
description: Safe, repeatable procedure for changing the D1 schema of the climate jobs board. Creates a numbered migration, applies it locally, updates types and tests, then verifies.
disable-model-invocation: true
argument-hint: short description of the schema change
---
# New migration
Change requested: $ARGUMENTS
## Steps
1. Create the migration: `npx wrangler d1 migrations create DB <name>`, with a short snake_case name. Confirm the new file has the next number in migrations/.
2. Write additive, backward-compatible SQL where possible. Add nullable columns or columns with defaults, and add new tables. Do not rename or drop columns in one step.
3. Add indexes on status, expires_at and published_at when a query filters or sorts on them.
4. Apply locally only: `npx wrangler d1 migrations apply DB --local`
5. Update the typed row types and query helpers in src/lib/db that touch the changed table.
6. Add or update tests in tests/ for the changed queries.
7. Run the verify skill and show its pass/fail table.
## Remote is human-only
Remind the human that remote migrations are theirs to run. Give them the exact command, for example `npx wrangler d1 migrations apply DB --remote`, and stop there.
Never run `--remote` migrations or `wrangler d1 execute --remote` yourself.
.claude/skills/stripe-webhook-test/SKILL.md---
name: stripe-webhook-test
description: Use when adding or changing Stripe webhook or Checkout handling on the climate jobs board, before writing the code, to get signed fixtures, raw-body checks, idempotency and required test cases.
---
# Stripe webhook test
Use this with the verify skill after any change to payment code.
## Before coding
- Use test mode keys only. Keep placeholders YOUR_STRIPE_SECRET_KEY and YOUR_STRIPE_WEBHOOK_SECRET in .dev.vars. Never print or commit them.
- Put fixture event JSON in tests/fixtures/stripe/.
- Sign fixtures with a test secret using the Web Crypto helper in tests/. Do not use Node crypto; the code runs on Workers.
## Handler rules
1. Verify the signature against the raw request body before parsing JSON. Fail closed: return 400 on any mismatch.
2. Store processed event ids in D1. If an id was already processed, return 200 and change nothing. Write the event id in the same db.batch as the state change. If the table does not exist yet, add it with the new-migration skill.
3. Only checkout.session.completed with a matching listing id moves draft to live. It also sets published_at and expires_at (published_at + 30 days).
4. Compare the amount in the Stripe event with the expected price. Never trust a client-supplied price or status.
5. Return 200 for other event types and log only the event type, with no PII.
## Required test cases
- Valid signature, correct event: listing moves to live with published_at and expires_at set.
- Bad signature: 400 and no D1 writes.
- Replayed event: no second state change, returns 200.
- Unknown listing id: no state change and no crash.
- Wrong amount: listing stays draft.
## Run
1. `npx vitest run` on the webhook test file.
2. The verify skill.
For local webhook testing, the human runs `stripe listen` in test mode.
.claude/skills/verify/SKILL.md---
name: verify
description: Run before saying any task is done on the climate jobs board. Runs lint, check, tests, build, a local smoke test and targeted checks for changed areas.
allowed-tools: Bash Read
---
# Verify
Run this before saying any task is done. Run the steps in order and stop at the first failure.
## Checks
1. `npm run lint`
2. `npm run check`
3. `npm test`
4. `npm run build`
5. If anything under migrations/ changed: `npx wrangler d1 migrations apply DB --local`
## Smoke run
1. Start the server in the background with `npx wrangler dev` or `npm run dev`. Note the port it prints.
2. Request `/` and expect 200.
3. Request one live `/jobs/[slug]` page and expect 200.
4. Request an expired seed slug from the seed script and expect 410.
5. Stop the server.
Use localhost only.
## Targeted checks
- Payment or webhook code changed (src/lib/stripe, src/pages/api/stripe, or related files): run the webhook test file and confirm the invalid-signature case fails closed.
- Digest code changed (src/lib/digest or src/lib/filters): run the digest tests and confirm a rerun sends zero duplicates.
## Report
Show a short pass/fail table:
| Step | Result |
|---|---|
| lint | pass or fail |
| check | pass or fail |
| test | pass or fail |
| build | pass or fail |
| migrations (if changed) | pass or fail |
| smoke run | pass or fail |
| targeted checks (if changed) | pass or fail |
On failure, show only the failing output, fix it, then rerun from the first failed step. Do not say done until every row passes.
Never run `wrangler deploy`, `wrangler d1 execute --remote`, or any Stripe live-mode call.
INSTALL.md# Setup guide
For the solo daily user.
## Prerequisites
- Node 20 or newer.
- Claude Code 2.1.296 or newer.
- A Cloudflare account, then run `npx wrangler login`.
- A Stripe test account. Use test mode keys only.
- A test API key from your email provider.
## Create the project
1. Run `claude update`, then `claude --version` to confirm 2.1.296 or newer.
2. Run `npm create astro@latest the app`, then `cd the app`.
3. Run `npx astro add cloudflare`.
4. Copy CLAUDE.md, SPEC.md, PLAN.md, REVIEW.md, INSTALL.md and `.claude/` into the repo root.
5. Run `git init` if needed, then commit.
## Set local secrets
1. Create `.dev.vars` in the repo root:
```
STRIPE_SECRET_KEY=YOUR_STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET=YOUR_STRIPE_WEBHOOK_SECRET
EMAIL_API_KEY=YOUR_EMAIL_API_KEY
```
2. Replace each placeholder with your test key.
3. Confirm `.dev.vars` is listed in `.gitignore`. Add it if it is missing.
## Install plugins
1. Add the design marketplace: `claude plugin marketplace add anthropics/skills`
2. Install the plugins:
```
claude plugin install code-review@claude-plugins-official
claude plugin install feature-dev@claude-plugins-official
claude plugin install security-guidance@claude-plugins-official
claude plugin install commit-commands@claude-plugins-official
claude plugin install example-skills@anthropic-agent-skills
```
The `example-skills` bundle includes frontend-design.
3. Start `claude`, run `/skill-doctor`, and fix anything it reports.
## Local webhook testing
- In a second terminal, run `stripe listen --forward-to localhost:8787/api/stripe/webhook`.
- Copy the signing secret that `stripe listen` prints into `.dev.vars` as the local webhook secret.
## Daily use
- Start new work in plan mode.
- Paste the Phase 1 `/goal` from PLAN.md to begin. Each later phase has its own `/goal`.
- Routine work runs on Sonnet, set in `.claude/settings.json`.
- Run `/effort high` or type `ultrathink` for Stripe and digest logic.
- Run `/clear` between phases and unrelated tasks. Run `/compact` at natural breaks.
- Run `/usage` to see where tokens went.
- After each phase, run `/code-review`, commit, then `/clear`.
## Human-only steps
Claude asks before running these. You run them yourself:
- `npx wrangler deploy`
- Remote D1 migrations
- Production secrets via `npx wrangler secret put`
PLAN.md# Build plan
Five phases. Each ends with a /goal prompt. Run one phase at a time, commit after each, then /clear.
Before any phase: start in plan mode, read CLAUDE.md, SPEC.md and PLAN.md, and confirm the plan with the human.
## Phase 1: Foundation
Scope:
- Astro with the Cloudflare adapter in SSR mode, TypeScript strict.
- vitest, eslint, prettier, npm scripts for dev, check, lint, test, build.
- wrangler.toml with a D1 binding named DB.
- migrations/0001_init.sql for listings and subscribers, with indexes on status, expires_at and published_at.
- Seed script: 8 live, 2 expired, 1 draft listing.
- Typed db helpers. Public queries require status = live AND expires_at > now.
- Pages: / (listing index) and /jobs/[slug] (410 for expired, 404 for draft or unknown).
```
/goal Phase 1 Foundation for the climate jobs board. Read CLAUDE.md, SPEC.md and PLAN.md first, then plan before coding.
Build: Astro with the @astrojs/cloudflare adapter in SSR mode, TypeScript strict, vitest, eslint, prettier. npm scripts dev, check, lint, test, build. wrangler.toml with D1 binding DB. migrations/0001_init.sql creating listings and subscribers tables as defined in SPEC.md, with indexes on status, expires_at, published_at. Seed script with 8 live, 2 expired and 1 draft listing. Typed db helpers in src/lib/db where every public query requires status = live AND expires_at > now. Pages / and /jobs/[slug] with a basic layout.
End state, all measurable:
- npm run lint, npm run check, npm test, npm run build each exit 0.
- npx wrangler d1 migrations apply DB --local succeeds.
- With the dev server running: curl / returns 200 and lists only the 8 live seed listings; a live /jobs/ slug returns 200; an expired slug returns 410; a draft slug returns 404.
- At least 6 tests cover the db helpers and the expiry rule.
- Run the verify skill and show its pass/fail table in the conversation.
Constraints:
- No wrangler deploy, no wrangler d1 execute --remote, no Stripe or email calls.
- No secrets in any file.
- No new dependencies beyond Astro, the Cloudflare adapter, vitest, eslint, prettier and wrangler without asking.
- Do not build payments or email yet.
Stop after 40 turns or when the end state is met, whichever comes first, and report.
```
## Phase 2: Posting and payments
Scope:
- Post form with validation and sanitization (markdown allow-list).
- Stripe Checkout session creation.
- Webhook with signature verification on the raw body (Web Crypto) and idempotency via processed event ids in D1.
- Status transitions: draft to live, sets published_at and expires_at.
- Tests with fixture events in tests/fixtures/stripe.
```
/goal Phase 2 Posting and payments for the climate jobs board. Read CLAUDE.md, SPEC.md (sections Core flows, Listing status, Pricing, Acceptance criteria A1 to A6) and REVIEW.md first, then plan before coding.
Build: post form with server-side validation and markdown sanitization; Stripe Checkout session creation using YOUR_PRICE_ID placeholder; POST /api/stripe/webhook verifying the signature on the raw body with Web Crypto, rejecting invalid signatures with 400, storing processed event ids in D1 so replays return 200 without changes; the webhook alone moves draft to live and sets published_at and expires_at = published_at + 30 days. Fixture events in tests/fixtures/stripe, signed with a test secret helper.
End state, all measurable:
- Webhook tests pass for: valid event, invalid signature (fails closed, no DB change), replayed event (no DB change), unknown listing id, wrong amount.
- npm run check, npm test and npm run lint exit 0.
- Form rejects a missing title and an invalid apply_url, and creates no row.
- Run the verify skill and show its pass/fail table, including the invalid-signature case.
Constraints:
- Test mode keys only. Use placeholders YOUR_STRIPE_SECRET_KEY and YOUR_STRIPE_WEBHOOK_SECRET. Never print or commit them.
- No wrangler deploy, no remote D1 commands, no Stripe live-mode calls.
- No new dependencies without asking. Ask before adding the Stripe SDK; if declined, use fetch.
- Use the stripe-webhook-test skill checklist.
Stop after 40 turns or when the end state is met, whichever comes first, and report.
```
## Phase 3: Expiry and search
Scope:
- Cron Trigger that marks listings expired when expires_at has passed. Re-runnable.
- 410 responses for expired listings.
- Filters with URL query params, using the pure match function in src/lib/filters.
- JobPosting JSON-LD and sitemap.
```
/goal Phase 3 Expiry and search for the climate jobs board. Read CLAUDE.md, SPEC.md (Filter model, SEO, acceptance criteria B1 to B4 and E1 to E2) and REVIEW.md first, then plan before coding.
Build: Cron Trigger handler that marks live listings with expires_at in the past as expired, safe to rerun. Filters (category, location, remote, seniority, keywords) as URL query params, using the pure match function in src/lib/filters with unit tests. JobPosting JSON-LD with validThrough from expires_at on detail pages. Sitemap listing live listings only.
End state, all measurable:
- Tests prove a listing at day 30 is expired and excluded from the list, the sitemap and the digest query.
- Tests prove the cron handler is idempotent when run twice.
- Filter tests cover each field and combined filters with AND.
- npm run check, npm test, npm run lint and npm run build exit 0.
- Run the verify skill and show its pass/fail table.
Constraints:
- No wrangler deploy, no remote D1 commands.
- No new dependencies without asking.
- Do not build the digest yet beyond the exclusion query.
Stop after 35 turns or when the end state is met, whichever comes first, and report.
```
## Phase 4: Subscribers and digest
Scope:
- Double opt-in subscriber flow with signed confirmation links.
- Saved filters stored as filters_json.
- Weekly cron that builds digests from listings published since last_digest_at.
- Idempotent sending through the email provider, with a fake sender in tests.
- Signed one-click unsubscribe.
```
/goal Phase 4 Subscribers and digest for the climate jobs board. Read CLAUDE.md, SPEC.md (Users, Filter model, acceptance criteria C1 to C3, D1 to D5, F1 to F2) and REVIEW.md first, then plan before coding. Use the feature-dev flow for this phase.
Build: double opt-in with signed confirmation links; saved filters in subscribers.filters_json; weekly cron that, per confirmed and non-unsubscribed subscriber, selects listings published since last_digest_at that match the pure filter function, skips the email when there are zero matches, records a sent row before or with the send, and updates last_digest_at. Email sending goes through a small interface with a fake implementation in tests and a real provider behind YOUR_EMAIL_API_KEY. Every email includes a signed one-click unsubscribe link.
End state, all measurable:
- Tests show a rerun of the digest cron sends zero duplicate emails.
- Tests show no email is sent for a subscriber with zero matches.
- Tests show a tampered unsubscribe token changes nothing, and a valid one sets unsubscribed_at.
- Tests show unconfirmed subscribers receive nothing.
- npm run check, npm test, npm run lint and npm run build exit 0.
- Run the verify skill and show its pass/fail table, including the digest rerun check.
Constraints:
- Use placeholder YOUR_EMAIL_API_KEY only. Never print or commit it.
- No real emails sent during development or tests.
- No wrangler deploy, no remote D1 commands.
- No new dependencies (including an email SDK) without asking.
- Email content must not include PII in logs.
Stop after 40 turns or when the end state is met, whichever comes first, and report.
```
## Phase 5: Hardening and launch
Scope:
- Rate limiting on forms.
- Spam checks (honeypot field and basic link limits).
- security-guidance and code-review pass against REVIEW.md.
- Accessibility and Lighthouse pass.
- Staging deploy checklist for the human to run.
```
/goal Phase 5 Hardening and launch prep for the climate jobs board. Read CLAUDE.md, SPEC.md (Open questions, SEO) and REVIEW.md first, then plan before coding.
Build: rate limiting on the post form, subscribe form and unsubscribe endpoint; honeypot field and a cap on links in descriptions; run /code-review and the security-guidance review against REVIEW.md and fix every real finding; accessibility fixes for the form, list and detail pages; a Lighthouse pass with fixes for anything under 90 in performance, accessibility or SEO; write docs/staging-checklist.md listing the steps a human runs (wrangler deploy, remote D1 migrations, production secrets, Stripe live keys, email domain).
End state, all measurable:
- All review findings from REVIEW.md are fixed or listed with a reason in docs/staging-checklist.md.
- Rate limit tests show the limit is enforced.
- Honeypot test shows a filled field creates nothing.
- npm run lint, npm run check, npm test and npm run build exit 0.
- Run the verify skill and show its pass/fail table.
Constraints:
- Do not run any deploy, remote migration, or secret command. The human does those.
- No secrets in files or logs.
- No new dependencies without asking.
Stop after 30 turns or when the end state is met, whichever comes first, and report.
```
REVIEW.md# Review checklist
Applies to every change. CLAUDE.md imports this file so `/code-review` uses it.
## Payments
- Webhook verifies the Stripe signature on the raw request body before parsing.
- Webhook handlers are idempotent. Processed event ids are stored in D1.
- Listing status is never set from client input. Only the Stripe webhook moves draft to live.
## Expiry
- Every public query filters `status = 'live' AND expires_at > now`.
- Expired listings return 410 and stay out of the sitemap and the digest.
## Email
- Every digest email has a working one-click unsubscribe link with a signed token.
- No duplicate sends. Sent rows are recorded before or with the send call.
- No PII (email addresses, tokens) in logs.
## Security
- Company HTML is sanitized. Only the allow-listed markdown passes through.
- SQL uses parameterized queries via `bind()`. No string concatenation.
- Secrets are absent from code, fixtures, logs and committed files.
- Public forms have rate limits.
## Workers compatibility
- No Node-only APIs such as `fs` or `child_process`. Use Web Crypto for signing.
- No unbounded CPU work in a request. Large digest work runs in cron.
## Tests
- New logic has tests, especially filter matching, expiry and digest.
## Reporting
- Flag only real issues. Group them by severity: blocker, high, medium.
- Skip style nits that lint already covers.
SPEC.md# Climate jobs board spec
## Summary
A paid job board for climate tech roles. Companies pay a flat fee to post a listing that stays live for 30 days. Job seekers can save filters and get one weekly email with new matching roles. Nothing is scraped. Every listing comes from the company that posts it.
## Users
- Company poster: no account. Posts with an email address and manages the listing through a magic link sent to that address.
- Job seeker: email-only subscriber. No password.
- Solo admin: the project owner. In v1, moderates through the database and CLI only.
## Core flows
1. Post a job: form, preview, Stripe Checkout, webhook publishes.
2. Browse and filter listings.
3. Save filters by email with double opt-in.
4. Weekly digest of new matches.
5. Listing expiry 30 days after publish.
6. Unsubscribe.
## Listing fields
- title
- company
- company_url
- location
- remote (boolean)
- category
- seniority
- salary_min and salary_max (optional)
- description_md (markdown, sanitized on save)
- apply_url or apply_email
- status (draft, paid, live, expired)
- published_at
- expires_at
## Listing status
- draft: form submitted, not paid. Not public.
- paid: Stripe confirmed payment. Set by the webhook.
- live: public. The webhook moves draft to live and sets published_at and expires_at.
- expired: expires_at has passed. Public pages return 410.
Only the Stripe webhook may move a listing to live.
## Filter model
- JSON shape: category[], location, remote, seniority[], keywords[].
- Match function lives in src/lib/filters. It is pure and fully tested.
- Keyword match is case-insensitive over title and description_md.
## Pricing
- One flat price: YOUR_PRICE_ID (Stripe price placeholder).
- Each payment buys one 30-day listing.
- Renewal is out of scope for v1.
## Non-goals
- Scraping or importing jobs from other sites.
- Applicant tracking.
- Seeker accounts with passwords.
- Employer dashboards.
- Multi-currency pricing.
## SEO
- Listing pages include JobPosting JSON-LD with validThrough set from expires_at.
- Sitemap lists live listings only.
- Expired listings return 410 and drop out of the sitemap.
## Acceptance criteria
Post a job:
- A1: Submitting a valid form creates a draft listing and redirects to Checkout.
- A2: Submitting with a missing title or invalid apply_url shows field errors and creates nothing.
- A3: Description HTML outside the markdown allow-list is stripped on save.
- A4: A valid, signed checkout.session.completed event makes the matching draft live with published_at and expires_at = published_at + 30 days.
- A5: An invalid signature changes nothing and returns 400.
- A6: A replayed event changes nothing and returns 200.
Browse and filter:
- B1: / lists only live listings with expires_at in the future.
- B2: Each filter (category, location, remote, seniority, keywords) narrows results, and combined filters use AND.
- B3: Filters are reflected in the URL query string and survive a reload.
- B4: A listing with expires_at in the past returns 410 on its detail page.
Save filters:
- C1: Submitting an email creates an unconfirmed subscriber and sends one confirmation link.
- C2: Clicking the confirmation link sets confirmed_at. No digest is sent before confirmation.
- C3: Invalid or expired confirmation tokens change nothing.
Weekly digest:
- D1: Each confirmed, non-unsubscribed subscriber gets at most one digest per week.
- D2: The digest contains only listings published since last_digest_at that match the saved filters.
- D3: No email is sent when there are zero matches.
- D4: Rerunning the cron job sends zero duplicate emails.
- D5: Every email has a working one-click unsubscribe link with a signed token.
Expiry:
- E1: A listing whose expires_at has passed is marked expired by the cron job.
- E2: Expired listings are excluded from list pages, the sitemap and the digest.
Unsubscribe:
- F1: A valid signed token sets unsubscribed_at and stops all future digests.
- F2: A tampered or malformed token changes nothing and shows an error page.
## Open questions
Pricing:
- What is the listing price, and is there a student or nonprofit rate?
- Should a listing be extendable before it expires?
Email provider:
- Which provider handles sending (Resend, Postmark, or Cloudflare Email)?
- Does the provider need a sending domain set up before launch?
Spam and moderation:
- Are listings reviewed by a human before going live, or only after payment?
- What happens to a listing that is reported?
- Is there a limit on posts per company email per day?