Skip to content

feat: invitation-only open letters (€10)#54

Open
xdamman-bot wants to merge 23 commits intocitizenspring:masterfrom
xdamman-bot:feature/invite-only-letters
Open

feat: invitation-only open letters (€10)#54
xdamman-bot wants to merge 23 commits intocitizenspring:masterfrom
xdamman-bot:feature/invite-only-letters

Conversation

@xdamman-bot
Copy link
Copy Markdown
Contributor

Invitation-Only Letters

Adds a new letter type: invitation-only (€10 to publish). Letters are always publicly readable, but signing is restricted via personal invitation links or email domain restrictions.

Phase 1: Backend (this commit)

  • DB migrations: new columns on letters + new invitations table
  • Invitation model with web-of-trust tracking (generation, invited_by, invites_remaining)
  • InvitationController: create/list/validate endpoints
  • Sign endpoint enforces invite-only restrictions (invite token or domain check)
  • API response includes letter_type, restriction_mode, is_paid, allowed_domains

Coming next

  • Phase 2: Stripe Checkout (€10 payment to activate)
  • Phase 3: Frontend — create flow type selector, signing gate, invite management
  • Phase 4: Homepage redesign + pricing page

See SPEC-invite-only.md for full specification.

- api/scripts/sync-donors.js (npm run cron): fetches Stripe checkout
  sessions for the donation payment link, builds donors.json
- api/scripts/seed-oc-donors.js (npm run seed:oc-donors): one-time
  script to snapshot current OC backers into donors.json
- frontend/components/DonorsList.js: replaces OpenCollectiveData,
  reads from /data/donors.json (static file, no API calls)
- Updated donate page and confirm_signature to use DonorsList
- Empty donors.json placeholder committed so frontend doesn't 404

Flow:
1. Run seed:oc-donors once to capture existing OC backers
2. Set up daily cron: STRIPE_SECRET_KEY=rk_xxx npm run cron
3. Cron merges Stripe donors with existing OC donors
4. donors.json is committed/deployed with the frontend
Scripts write to frontend/public/data/ so they belong in the frontend
package. Moved sync-donors.js and seed-oc-donors.js to frontend/scripts/,
updated output paths, moved stripe dep from api to frontend.
Reads the optional 'displaynameforthedonorslist' custom field from
checkout sessions. Donors who leave it blank remain anonymous (skipped).
Use CREDIT transactions instead of members query to get per-donor
amount totals and earliest donation date. Aggregates multiple
donations per person into one entry.
- Top 10 contributors by amount in a featured section
- All contributors sorted by date (newest first), show 10 initially
- 'Show all' button to expand the full list
- Compact mode (inline names) used on post-signature confirmation
Skips the actual confirmation flow and shows the signed state
with the compact donors list. Usage:
/any-letter/confirm_signature?preview=confirmed
…lative dates

- Filter out 'Citizen Spring' from OC seed script
- No links on any donor names
- All contributors list shows relative date (e.g. '3 months ago')
Fetches expenses from OC GraphQL, stores in donors.json, displays
them on /donate with description, amount and relative date.
Matches the current live layout:
1. Cost summary cards (total spent, daily avg, monthly avg)
2. Latest 5 expenses with status
3. Top 10 contributors
4. All contributors by date with show more

All powered by static donors.json instead of live OC GraphQL.
Adds prebuild script so donors.json is refreshed from
OpenCollective on every Vercel deploy. Falls back gracefully
(|| true) if the OC API is unreachable.
The OC GraphQL v1 API returns createdAt as human-readable strings
(e.g. 'Sun Mar 29 2026 23:36:03 GMT+0000'), not ISO-8601.
The old .split('T')[0] was splitting on the T in 'GMT', producing
broken date strings that caused NaN in the frontend.

Now uses new Date() → toISOString() → YYYY-MM-DD for robust parsing.
Without this, only OC donors appear — Stripe donors were missing
entirely. sync-donors.js gracefully exits if STRIPE_SECRET_KEY
is not set.
sync-donors.js was overwriting donors.json without carrying over
the expenses and ocLegacy fields written by seed-oc-donors.js.
- Migration: add letter_type, restriction_mode, allowed_domains,
  invites_per_person, allow_chain_invites, is_paid, stripe_session_id,
  deleted_at columns to letters table
- Migration: create invitations table with web-of-trust tracking
  (token, email, invited_by, generation, invites_remaining, signature_id)
- New Invitation model with letter/parent/children/signature relations
- InvitationController: create (batch invite by email), list (creator),
  validate (public invite token check)
- LetterController.sign: enforce invite-only restrictions
  - restriction_mode=invite: require valid invite_token
  - restriction_mode=domain: validate email against allowed_domains
  - Link invitation to signature after signing
- LetterController.get: expose letter_type, restriction_mode, is_paid,
  allowed_domains in API response
- Letter.createWithLocales: accept invite-only fields
- New API routes: POST/GET letters/:slug/invitations, GET invitations/:token
@coolify-github-citizenspring
Copy link
Copy Markdown

coolify-github-citizenspring Bot commented Apr 5, 2026

The preview deployment for openletter is ready. 🟢

Open Preview | Open Build Logs | Open Application Logs

Last updated at: 2026-04-05 18:31:33 CET

- StripeController: createCheckout (creates €10 Checkout session),
  verifyPayment (checks payment status on redirect), webhook handler
  (verifies via Stripe API instead of raw body signature — Adonis v4 compat)
- Routes: POST letters/:slug/checkout, POST letters/:slug/verify-payment,
  POST webhooks/stripe
- Added stripe dependency to api/package.json

Flow: creator creates invite-only letter → redirected to Stripe Checkout →
on success, letter.is_paid = true → invitations can be sent.
Both webhook and verify-payment endpoints can activate the letter,
providing redundancy.
Stripe integration:
- StripeController: createCheckout (€10 session), verifyPayment (status
  check on redirect), webhook (verifies via Stripe API)
- Added stripe dependency to api/package.json
- Routes: POST letters/:slug/checkout, verify-payment, webhooks/stripe

Frontend:
- LetterForm: letter type selector (Public vs Invitation-only €10),
  restriction mode (invite links vs email domain), invite settings
  (invites per person, chain invites toggle, allowed domains)
- create.js: redirects to Stripe Checkout for invite-only letters
- [slug]/index.js: gates signing form behind invite token or domain
  check, shows appropriate messaging for invite-only letters
- [slug]/manage.js: invitation management dashboard — send invites,
  view status (invited/signed/pending), domain mode info
- pricing.js: pricing page with Public vs Invitation-only comparison,
  explains anti-bot benefits, web of trust, genuine signatures
- Footer: added Pricing link
- en.json: 60+ new i18n keys for all new UI
- Updated FAQ answers for 'can I limit who signs'
- Hero section with stats and dual CTA (public free vs invite-only €10)
- NEW badge section highlighting invitation-only: stop AI bots,
  web of trust, more weight for your letter
- 4-step 'how it works' with public/invite distinction
- Values section in card grid
- Cleaner structure, removed inline SVG duplication
- 15+ new i18n keys for homepage content
LetterForm:
- Replaced all styled-components with Tailwind classes
- Clean rounded inputs with focus rings, serif title font
- Inline preview panel (toggle show/hide) renders markdown-ish
  bold/italic with live title and image
- Auto-saves to localStorage on every keystroke (debounce via setState callback)
- Restores draft on page load — survives crashes, tab closes, refreshes
- Exports DRAFT_KEY constant for external clear

create.js:
- Clean centered layout with 2/3 form + 1/3 sidebar (FAQ + draft indicator)
- DraftIndicator component shows green pulse dot + last save time
- Clear draft button to start fresh
- Draft only cleared after confirmed publish (Router.push) or Stripe redirect
- Page title and heading via i18n

New i18n keys: create.page.*, create.preview.*
Replaced the confusing 'letter type' framing with 'Who can sign?':

- Clear subtitle: 'Your letter is always public and readable by
  everyone. Choose who can add their signature.'
- Three distinct options:
  ✅ Open to everyone (free)
  🔗 Personal invitation required (€10) — own column with settings
  🏛️ Email domain restriction (€10) — own column with settings
- Each restricted option is a separate selectable card, not a toggle
  inside a collapsed section
- Settings (invites per person, chain invites, domain list) appear
  directly below the selected option
- Removed 'letter type' terminology entirely from the UI
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