Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7fdb586
feat: replace OpenCollective with Stripe donors list
xdamman-bot Apr 4, 2026
1ad5359
fix: move donor scripts + stripe dep to frontend
xdamman-bot Apr 4, 2026
ecce31c
fix: use node-fetch in seed-oc-donors (Node 16 compat)
xdamman-bot Apr 4, 2026
2eccbda
fix: use custom_fields display name for Stripe donors
xdamman-bot Apr 4, 2026
3544a94
feat: fetch OC donor amounts and dates via transactions API
xdamman-bot Apr 4, 2026
5e8e797
feat: donors page with top 10 + show more
xdamman-bot Apr 4, 2026
9afc641
fix: compact donors list shows 20 latest by date
xdamman-bot Apr 4, 2026
cc99e32
feat: add ?preview=confirmed to test confirm_signature page
xdamman-bot Apr 4, 2026
970315f
fix: exclude Citizen Spring (fiscal host), remove donor links, add re…
xdamman-bot Apr 4, 2026
3cf5bff
feat: include latest 5 OC expenses in donors.json and donate page
xdamman-bot Apr 4, 2026
02bc51e
feat: show expenses + cost breakdown below donate button
xdamman-bot Apr 4, 2026
fbaa9f3
fix: run seed:oc-donors automatically before build
xdamman-bot Apr 5, 2026
ae3c614
fix: update donors.json with actual data
xdamman-bot Apr 5, 2026
52c83ab
fix: parse OC dates properly — fixes NaN on donate page
xdamman-bot Apr 5, 2026
d2c7109
fix: also run Stripe sync in prebuild
xdamman-bot Apr 5, 2026
1bf8260
fix: preserve expenses and ocLegacy when sync-donors runs
xdamman-bot Apr 5, 2026
8f69158
feat: invitation-only letters — Phase 1 (backend)
xdamman-bot Apr 5, 2026
371567f
feat: Stripe Checkout for invite-only letters (Phase 2)
xdamman-bot Apr 5, 2026
f71c96f
feat: invitation-only letters — Phase 2 (Stripe) + Phase 3 (frontend)
xdamman-bot Apr 5, 2026
9bcb13c
feat: redesign homepage for public + invite-only (Phase 4)
xdamman-bot Apr 5, 2026
027dcbe
fix: remove deleted_at from migration (likely already exists in prod)
xdamman-bot Apr 5, 2026
7eaf838
feat: redesign /create page with preview + localStorage drafts
xdamman-bot Apr 5, 2026
2cc3a52
fix: redesign signing restriction UI — clear 3-option layout
xdamman-bot Apr 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions SPEC-invite-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Spec: Invitation-Only Open Letters

## Overview
Add a new letter type: **invitation-only** (€10 to publish). Letters are always publicly readable, but signing can be restricted via personal invitation links and/or email domain restrictions.

## Letter Types
- **Public** (free) — anyone can sign. Current behavior, unchanged.
- **Invitation-only** (€10) — signing restricted. Creator pays via Stripe Checkout to publish/activate invitations.

## User Flow

### Creating an invitation-only letter
1. Creator writes the letter on `/create` (same as today)
2. New toggle: "Public (free)" vs "Invitation only (€10)"
3. If invitation-only, additional fields appear:
- **Restriction mode**: "Invite links only" or "Email domain restriction"
- If invite links: **Invites per person** (number, e.g. 5) — how many people each invitee can invite
- If invite links: **Allow chain invites** — toggle (off = flat/1 level, on = infinite depth)
- If domain restriction: **Allowed domains** — comma-separated list (e.g. `university.edu, department.org`)
4. Letter is created as a **draft** (visible, readable, but signing disabled)
5. Creator is redirected to Stripe Checkout (€10) to pay
6. On successful payment (Stripe webhook or redirect), letter becomes **active** — invitations work, signing is enabled

### Inviting signers (invitation-only letters)
1. After payment, creator lands on a management page (`/{slug}/manage?token=xxx`)
2. Creator can:
- Enter email addresses to invite (sends email with personal invite link)
- Copy a personal invite link to share manually
- See who has been invited, who has signed, who invited whom (web of trust tree)
3. Each invite link is unique: `/{slug}?invite=<token>`
4. When an invitee signs, they get their own invite links (up to the per-person limit)
5. Chain depth: if disabled, only creator's direct invitees can invite; if enabled, invitees of invitees can also invite, recursively

### Signing an invitation-only letter
1. Visitor arrives at `/{slug}` — can always **read** the letter and see signatures
2. If no valid `?invite=<token>` in URL:
- Show message: "This letter is invitation-only. You need an invitation link to sign it."
3. If valid invite token:
- Normal signing flow (email or passkey)
- After signing, show their own invite links (if they have invite slots)
4. If domain restriction mode:
- Email field is required (no passkey-only)
- Email must match one of the allowed domains
- No invite tokens needed — anyone with a matching email can sign

## Data Model

### Letters table — new columns
```sql
ALTER TABLE letters ADD COLUMN letter_type VARCHAR(16) DEFAULT 'public'; -- 'public' | 'invite_only'
ALTER TABLE letters ADD COLUMN restriction_mode VARCHAR(16) DEFAULT NULL; -- 'invite' | 'domain' | NULL
ALTER TABLE letters ADD COLUMN allowed_domains TEXT DEFAULT NULL; -- JSON array: ["university.edu","dept.org"]
ALTER TABLE letters ADD COLUMN invites_per_person INTEGER DEFAULT 5; -- how many invites each person gets
ALTER TABLE letters ADD COLUMN allow_chain_invites BOOLEAN DEFAULT FALSE; -- flat (false) vs infinite depth (true)
ALTER TABLE letters ADD COLUMN is_paid BOOLEAN DEFAULT FALSE; -- payment completed?
ALTER TABLE letters ADD COLUMN stripe_session_id VARCHAR(255) DEFAULT NULL; -- Stripe Checkout session ID
```

### New table: invitations
```sql
CREATE TABLE invitations (
id SERIAL PRIMARY KEY,
letter_id INTEGER NOT NULL REFERENCES letters(id),
token VARCHAR(64) NOT NULL UNIQUE, -- unique invite token
email VARCHAR(255) DEFAULT NULL, -- invited email (optional, for tracking)
invited_by INTEGER DEFAULT NULL REFERENCES invitations(id), -- parent invitation (NULL = creator)
generation INTEGER DEFAULT 0, -- 0 = creator, 1 = direct invitee, 2 = their invitee...
invites_remaining INTEGER DEFAULT 5, -- how many invites this person can still send
signature_id INTEGER DEFAULT NULL REFERENCES signatures(id), -- linked signature once they sign
used_at TIMESTAMP DEFAULT NULL, -- when they signed
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```

## API Endpoints

### New endpoints
- `POST /letters/:slug/invitations` — create invitation(s) (requires letter token auth)
- Body: `{ token, emails: ["a@b.com"], invite_token: "xxx" }` (invite_token for chain invites)
- Returns: array of invitation objects with invite URLs
- `GET /letters/:slug/invitations?token=xxx` — list all invitations for a letter (requires letter token)
- `GET /invitations/:invite_token` — validate an invite token, return letter info
- `POST /letters/:slug/checkout` — create Stripe Checkout session for €10
- `POST /webhooks/stripe` — handle Stripe payment webhooks

### Modified endpoints
- `POST /letters/:slug/:locale/sign` — check invitation validity before allowing signature
- If letter_type='invite_only' and restriction_mode='invite': require valid invite_token in body
- If letter_type='invite_only' and restriction_mode='domain': validate email domain
- `GET /letters/:slug` — include `letter_type`, `restriction_mode`, `is_paid` in response
- `POST /letters/create` — accept `letter_type` and related fields

## Frontend Pages

### Modified pages
- `/create` — add letter type selector + invite settings
- `/{slug}` — show invite-only messaging, gate signing form behind invite token
- `/{slug}/confirm_signature` — after confirming, show invite links if applicable

### New pages
- `/{slug}/manage?token=xxx` — invitation management dashboard (creator only)
- `/pricing` — new pricing page explaining Public vs Invitation-only

### Homepage redesign
- Hero: emphasize the two options (public free letter vs invitation-only)
- Value prop for invitation-only: "Stop AI bots. Ensure genuine signatures. Give more weight to your open letter."
- Social proof: stats (3,600+ letters, 490,000+ signatures)
- Clear CTA: "Create a Public Letter (free)" and "Create an Invitation-Only Letter (€10)"

## Stripe Integration
- New Stripe Payment Link or Checkout Session for €10
- On success: set `is_paid = true` on the letter
- Webhook endpoint to handle async payment confirmation
- Use existing Stripe setup (already have `stripe` npm package in frontend)

## i18n
- Add new translation keys for all new UI strings (en, fr, nl, de)
- Key examples: `create.type.public`, `create.type.invite_only`, `pricing.title`, `invite.message`, etc.
208 changes: 208 additions & 0 deletions api/app/Controllers/Http/InvitationController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
'use strict'

const crypto = use('crypto')
const Letter = use('App/Models/Letter')
const Invitation = use('App/Models/Invitation')
const { sendEmail } = use('App/Libs/email')

class InvitationController {
/**
* Create invitation(s) for a letter.
* Can be called by the letter creator (with letter token) or by an invitee (with invite_token).
*
* Body: { token?, invite_token?, emails: ["a@b.com"] }
*/
async create ({ request, params }) {
const { token, invite_token, emails } = request.only(['token', 'invite_token', 'emails'])

// Find the letter (any locale version, we just need the slug-level settings)
const letter = await Letter.query()
.where('slug', params.slug)
.where('letter_type', 'invite_only')
.first()

if (!letter) {
return { error: { code: 404, message: 'Letter not found or is not invite-only' } }
}

if (!letter.is_paid) {
return { error: { code: 402, message: 'Payment required to activate invitations' } }
}

let parentInvitation = null
let generation = 0
let invitesPerPerson = letter.invites_per_person

if (invite_token) {
// Invitee creating sub-invitations
parentInvitation = await Invitation.query()
.where('token', invite_token)
.where('letter_id', letter.id)
.first()

if (!parentInvitation) {
return { error: { code: 403, message: 'Invalid invitation token' } }
}

if (!parentInvitation.used_at) {
return { error: { code: 403, message: 'You must sign the letter before inviting others' } }
}

// Check if chain invites are allowed
if (!letter.allow_chain_invites && parentInvitation.generation > 0) {
return { error: { code: 403, message: 'Chain invitations are not allowed for this letter' } }
}

if (parentInvitation.invites_remaining <= 0) {
return { error: { code: 403, message: 'No invitations remaining' } }
}

generation = parentInvitation.generation + 1
} else if (token) {
// Letter creator
if (token !== letter.token) {
return { error: { code: 403, message: 'Unauthorized: Invalid token' } }
}
} else {
return { error: { code: 400, message: 'Either token or invite_token is required' } }
}

if (!emails || !Array.isArray(emails) || emails.length === 0) {
return { error: { code: 400, message: 'At least one email address is required' } }
}

// Limit batch size
const emailsToInvite = emails.slice(0, parentInvitation
? Math.min(emails.length, parentInvitation.invites_remaining)
: 50
)

const created = []
for (const email of emailsToInvite) {
// Check if already invited
const existing = await Invitation.query()
.where('letter_id', letter.id)
.where('email', email.toLowerCase())
.first()

if (existing) continue

const inviteToken = crypto.randomBytes(24).toString('hex')

const invitation = await Invitation.create({
letter_id: letter.id,
token: inviteToken,
email: email.toLowerCase(),
invited_by: parentInvitation ? parentInvitation.id : null,
generation,
invites_remaining: invitesPerPerson,
})

// Send invite email
try {
await sendEmail(
email.toLowerCase(),
`You're invited to sign: ${letter.title}`,
'emails.en.invitation',
{
letter: letter.toJSON(),
inviteUrl: `${process.env.FRONTEND_URL || 'https://openletter.earth'}/${letter.slug}?invite=${inviteToken}`,
env: process.env,
}
)
} catch (e) {
console.error('Failed to send invite email to', email, e)
}

created.push({
id: invitation.id,
email: invitation.email,
invite_url: `${process.env.FRONTEND_URL || 'https://openletter.earth'}/${letter.slug}?invite=${inviteToken}`,
generation: invitation.generation,
})
}

// Decrement parent invitation's remaining count
if (parentInvitation) {
parentInvitation.invites_remaining -= created.length
await parentInvitation.save()
}

return { invitations: created }
}

/**
* List all invitations for a letter (creator only, requires letter token).
*/
async list ({ request, params }) {
const { token } = request.only(['token'])

const letter = await Letter.query()
.where('slug', params.slug)
.first()

if (!letter) {
return { error: { code: 404, message: 'Letter not found' } }
}

if (token !== letter.token) {
return { error: { code: 403, message: 'Unauthorized: Invalid token' } }
}

const invitations = await Invitation.query()
.where('letter_id', letter.id)
.with('signature')
.orderBy('created_at', 'asc')
.fetch()

return {
invitations: invitations.toJSON().map(inv => ({
id: inv.id,
email: inv.email,
generation: inv.generation,
invited_by: inv.invited_by,
invites_remaining: inv.invites_remaining,
used_at: inv.used_at,
signature: inv.signature,
created_at: inv.created_at,
})),
}
}

/**
* Validate an invite token (public endpoint).
* Returns letter info if valid.
*/
async validate ({ params }) {
const invitation = await Invitation.query()
.where('token', params.token)
.with('letter')
.first()

if (!invitation) {
return { error: { code: 404, message: 'Invalid invitation' } }
}

if (invitation.used_at) {
return { error: { code: 400, message: 'This invitation has already been used' } }
}

const letter = invitation.getRelated('letter')

return {
valid: true,
invitation: {
id: invitation.id,
email: invitation.email,
generation: invitation.generation,
invites_remaining: invitation.invites_remaining,
},
letter: {
slug: letter.slug,
title: letter.title,
},
}
}
}

module.exports = InvitationController
Loading