# Ghost Removal Plan

**Replace Ghost CMS entirely with native infrastructure.**

---

## What Ghost Currently Provides

Ghost is deeply integrated across three major areas:

### 1. Content Management (blog service)
- Post/page storage in Lexical JSON format
- Author and tag entities with many-to-many relationships
- WYSIWYG editing via Ghost Admin API
- Media uploads (images, audio/video, files) via Ghost's upload endpoints
- OEmbed lookups for embedded media
- Content sync: Ghost → local DB via Content API + webhooks
- ActivityPub publishing triggered after post sync

### 2. Membership & Subscriptions (account service)
- **Members**: Ghost is the member store — users have `ghost_id`, synced bidirectionally
- **Labels**: tagging/segmentation of members (M2M via `GhostLabel` / `UserLabel`)
- **Newsletters**: newsletter entities with per-user subscription tracking (`GhostNewsletter` / `UserNewsletter` with `subscribed` flag)
- **Tiers**: membership levels (free/paid) stored in `GhostTier`
- **Subscriptions**: paid plans with Stripe integration — cadence, price, Stripe customer/subscription IDs stored in `GhostSubscription`
- **Bidirectional sync**: Ghost → DB (`sync_all_membership_from_ghost`, `sync_single_member`) and DB → Ghost (`sync_member_to_ghost`)

### 3. Infrastructure
- JWT token generation for Admin API (`ghost_admin_token.py`)
- Webhook handlers for real-time sync (member, post, page, author, tag events)
- Email campaign sending (newsletter selection on publish, `email_segment` parameter)
- Stripe payment processing for paid subscriptions (handled entirely by Ghost)
- Ghost Docker container (Node.js app alongside our Python stack)

### Environment Variables
```
GHOST_API_URL, GHOST_ADMIN_API_URL, GHOST_PUBLIC_URL
GHOST_CONTENT_API_KEY, GHOST_ADMIN_API_KEY
GHOST_WEBHOOK_SECRET
```

### Ghost-Related Files
```
blog/bp/blog/ghost/ghost_sync.py          # Content fetch & sync
blog/bp/blog/ghost/ghost_posts.py         # Post CRUD via Admin API
blog/bp/blog/ghost/ghost_admin_token.py   # JWT generation
blog/bp/blog/ghost/lexical_validator.py   # Lexical JSON validation
blog/bp/blog/ghost/editor_api.py          # Media upload proxy
blog/bp/blog/ghost_db.py                  # Ghost DB client
blog/bp/blog/web_hooks/routes.py          # Webhook handlers
shared/infrastructure/ghost_admin_token.py # JWT generation (shared copy)
shared/models/ghost_content.py            # Post, Author, Tag, junction tables
shared/models/ghost_membership_entities.py # Label, Newsletter, Tier, Subscription
account/services/ghost_membership.py       # Membership sync service
```

### Ghost-Related Database Tables
```
# Content
posts           (ghost_id, uuid, slug, title, status, lexical, html, ...)
authors         (ghost_id, slug, name, email, ...)
tags            (ghost_id, slug, name, ...)
post_authors    (post_id, author_id, sort_order)
post_tags       (post_id, tag_id, sort_order)

# Membership
ghost_labels        (ghost_id, name, slug)
user_labels         (user_id, label_id)
ghost_newsletters   (ghost_id, name, slug, description)
user_newsletters    (user_id, newsletter_id, subscribed)
ghost_tiers         (ghost_id, name, slug, type, visibility)
ghost_subscriptions (ghost_id, user_id, status, cadence, price_amount,
                     price_currency, stripe_customer_id, stripe_subscription_id,
                     tier_id, raw)

# User model fields
users.ghost_id, users.ghost_status, users.ghost_subscribed,
users.ghost_note, users.ghost_raw, users.stripe_customer_id
```

---

## Problems

- **Two sources of truth** for content AND membership — constant sync overhead
- Every edit round-trips through Ghost's API — we don't own the write path
- Ghost sync is fragile (advisory locks, error recovery, partial sync states)
- Lexical JSON is opaque — we validate but never truly control the format
- Ghost is an entire Node.js application running alongside our Python stack
- Stripe integration is locked inside Ghost — we can't customize payment flows
- Newsletter/email is Ghost-native — no control over templates, scheduling, deliverability
- Membership tiers are Ghost concepts that don't map cleanly to our cooperative model

---

## Target State

Everything Ghost does is handled natively by our services:

| Ghost Feature | Replacement |
|---|---|
| Post/page content | Sexp in `posts.body_sexp` column |
| Lexical editor | WYSIWYG editor saving sexp directly to DB |
| Media uploads | Direct upload to our storage (S3/local) — blog service endpoint |
| Authors | Already in our DB — just drop `ghost_id` column |
| Tags | Already in our DB — just drop `ghost_id` column |
| Members | Already our `users` table — drop Ghost sync, Ghost fields |
| Labels | Rename `ghost_labels` → `labels`, drop `ghost_id` |
| Newsletters | Native newsletter service (see Phase 7 below) |
| Tiers | Native membership tiers on `account` service |
| Subscriptions | Direct Stripe integration on `orders` service (already has SumUp) |
| Email sending | Transactional email service (Postmark/SES/SMTP) |
| Webhooks | Not needed — we own the write path |
| Ghost Docker container | Removed entirely |

---

## Migration Phases

### Phase 1: Lexical → Sexp Converter

Write a one-time conversion script that transforms Lexical JSON into equivalent sexp.

| Lexical Node | S-expression |
|---|---|
| `paragraph` | `(p ...)` |
| `heading` (level 1-6) | `(h1 ...)` ... `(h6 ...)` |
| `text` (plain) | `"string"` |
| `text` (bold) | `(strong "string")` |
| `text` (italic) | `(em "string")` |
| `text` (bold+italic) | `(strong (em "string"))` |
| `text` (code) | `(code "string")` |
| `link` | `(a :href "url" "text")` |
| `list` (bullet) | `(ul (li ...) ...)` |
| `list` (number) | `(ol (li ...) ...)` |
| `quote` | `(blockquote ...)` |
| `image` | `(use "image" :src "url" :alt "text" :caption "text")` |
| `code-block` | `(pre (code :class "language-x" "..."))` |
| `divider` | `(hr)` |
| `embed` | `(use "embed" :url "..." :type "...")` |

Run against all existing posts, verify round-trip fidelity by rendering both versions and comparing HTML output.

### Phase 2: Schema Changes — Content

- Add `body_sexp` text column to `Post` model (or repurpose `lexical` column)
- Keep all existing metadata columns (title, slug, status, published_at, feature_image, etc.)
- Drop `ghost_id` from `Post`, `Author`, `Tag` tables (after full migration)
- Drop `mobiledoc` column (legacy Ghost format, unused)

### Phase 3: Editor Integration

Update the WYSIWYG editor to save sexp instead of Lexical JSON:

- Editor toolbar actions produce sexp nodes
- Save endpoint writes directly to our DB (no Ghost Admin API call)
- Preview renders via the same sexp pipeline used for the public view
- Draft/publish workflow stays the same — just a `status` column update

### Phase 4: Media Uploads

Replace Ghost's upload proxy with native endpoints on the blog service:

- `POST /admin/upload/image/` — accept image, store to S3/local, return URL
- `POST /admin/upload/media/` — audio/video
- `POST /admin/upload/file/` — generic files
- `GET /admin/oembed/?url=...` — OEmbed lookup (call providers directly)

The editor already posts to proxy endpoints in `editor_api.py` — just retarget them to store directly rather than forwarding to Ghost.

### Phase 5: Rendering Pipeline

Update `post_data()` and related functions:

- Parse `body_sexp` through the sexp evaluator
- Render to HTML via the existing `shared/sexp/html.py` pipeline
- Components referenced in post content (`use "image-gallery"`, etc.) resolve from the component registry
- Context variables (author data, related posts, etc.) passed as environment bindings

### Phase 6: Membership Decoupling

Migrate membership from Ghost to native account service:

**Labels → native labels:**
- Rename `ghost_labels` → `labels`, drop `ghost_id` column
- `user_labels` stays as-is
- Admin UI manages labels directly (no Ghost sync)

**Tiers → native membership tiers:**
- Rename `ghost_tiers` → `membership_tiers`, drop `ghost_id`
- Add tier management to account admin UI
- Tier assignment logic moves from Ghost webhook handler to account service

**User model cleanup:**
- Drop: `ghost_id`, `ghost_status`, `ghost_subscribed`, `ghost_note`, `ghost_raw`
- Keep: `stripe_customer_id` (needed for direct Stripe integration)
- Add: `membership_tier_id` FK, `membership_status` enum (free/active/cancelled)

### Phase 7: Newsletter System

Replace Ghost's newsletter infrastructure with a native implementation:

**Newsletter model (replaces `ghost_newsletters`):**
```
newsletters (id, name, slug, description, from_email, reply_to, template_sexp, created_at)
user_newsletters (user_id, newsletter_id, subscribed, subscribed_at, unsubscribed_at)
```

**Email sending:**
- Integrate a transactional email provider (Postmark, AWS SES, or direct SMTP)
- Newsletter templates as sexp — rendered to HTML email via the same pipeline
- Send endpoint on account or blog service: select newsletter, select segment (by label/tier), queue sends
- Unsubscribe handling: tokenized unsubscribe links, one-click List-Unsubscribe header

**Post → email campaign:**
- On publish, optionally select newsletter + segment (replaces Ghost's `?newsletter=slug&email_segment=...`)
- Render post body sexp to email-safe HTML (inline styles, table layout for email clients)
- Queue via background task (Celery or async worker)

**What we gain over Ghost:**
- Email templates are sexp — same format as everything else
- Full control over deliverability (SPF/DKIM/DMARC on our domain)
- Segment by any user attribute, not just Ghost's limited filter syntax
- Send analytics stored in our DB

### Phase 8: Subscription & Payment

Replace Ghost's Stripe integration with direct Stripe on the orders service:

**Current state:** Orders service already handles SumUp payments for marketplace/events. Adding Stripe for recurring subscriptions follows the same pattern.

**Implementation:**
- Stripe Checkout for subscription creation (redirect flow, PCI compliant)
- Stripe Webhooks for subscription lifecycle (created, updated, cancelled, payment_failed)
- `subscriptions` table (replaces `ghost_subscriptions`):
  ```
  subscriptions (id, user_id, tier_id, stripe_subscription_id, stripe_customer_id,
                 status, cadence, price_amount, price_currency,
                 current_period_start, current_period_end, cancelled_at)
  ```
- Customer portal: link to Stripe's hosted portal for card updates/cancellation
- Webhook handler on orders service (same pattern as SumUp webhooks)

**What we gain:**
- Unified payment handling (SumUp for one-off, Stripe for recurring)
- Custom subscription logic (cooperative membership models, sliding scale, etc.)
- Direct access to Stripe customer data without Ghost intermediary

### Phase 9: Remove Ghost

Delete all Ghost integration code:

| File/Directory | Action |
|---|---|
| `blog/bp/blog/ghost/` | Delete entire directory |
| `blog/bp/blog/ghost_db.py` | Delete |
| `blog/bp/blog/web_hooks/` | Delete |
| `shared/infrastructure/ghost_admin_token.py` | Delete |
| `account/services/ghost_membership.py` | Delete |
| Ghost Docker service | Remove from docker-compose |
| Ghost env vars | Remove all `GHOST_*` variables |
| Ghost webhook blueprint registration | Remove from blog routes |
| Startup sync (`sync_all_content_from_ghost`) | Remove from blog init |
| Startup sync (`sync_all_membership_from_ghost`) | Remove from account init |
| Advisory lock `900001` | Remove from blog init |

Rename models:
- `ghost_content.py` → `content.py`
- `ghost_membership_entities.py` → `membership.py`
- Drop all `ghost_id` columns via Alembic migration

### Phase 10: Content-Addressable Caching (ties into sexpr.js)

Once posts are sexp and the JS client runtime exists:

- Hash post body → content address
- Client caches post bodies in localStorage keyed by hash
- Server sends manifest of slug → hash mappings
- Unchanged posts served entirely from client cache
- Only the data envelope (metadata, component params) travels on repeat visits

---

## What Stays the Same

- `Post` model and all its metadata fields (minus ghost-specific ones)
- URL structure (`/slug/`)
- Tag, author, and tag group systems
- Draft/publish workflow
- Admin edit UI (updated to save sexp instead of Lexical)
- RSS feeds (rendered from sexp → HTML)
- Search indexing (extract text content from sexp)
- ActivityPub federation (triggered on publish, same as now)
- Alembic migrations (add/modify/drop columns)
- OAuth2 auth system (already independent of Ghost)

---

## Ordering & Dependencies

```
Phase 1-2 (Content schema)     ──→ Phase 3 (Editor)    ──→ Phase 5 (Rendering)
                                   Phase 4 (Uploads)    ──┘
Phase 6 (Membership)            ──→ Phase 8 (Payments)
Phase 7 (Newsletters)           ── independent, needs email provider choice
Phase 9 (Remove Ghost)          ── after all above complete
Phase 10 (Content-addressed)    ── after sexpr.js runtime exists
```

Phases 1-5 (content) and Phases 6-8 (membership/payments) can proceed in parallel — they touch different services.

---

## Risk Mitigation

- **Data safety**: Run Lexical → sexp converter in dry-run mode first, diff HTML output for every post
- **Rollback**: Keep `lexical` column and Ghost running during transition, feature flag to switch renderers
- **Editor UX**: Editor remains WYSIWYG — authors never see sexp syntax
- **SEO continuity**: URLs don't change, HTML output structurally identical
- **Email deliverability**: Set up SPF/DKIM/DMARC before sending first newsletter from our domain
- **Payment migration**: Run Ghost Stripe and direct Stripe in parallel during transition, migrate active subscriptions via Stripe API (change the subscription's application)
- **Membership data**: One-time migration script to clean User model fields, verified against Ghost export

---

## Dependencies

- Stable sexp parser + evaluator (already built: `shared/sexp/`)
- Component registry with post-relevant components: image, embed, gallery, code-block
- Editor sexp serialization (new work)
- Email provider account (Postmark/SES/SMTP)
- Stripe account with recurring billing enabled (may already exist via Ghost)
- Optional: sexpr.js client runtime for content-addressable caching (see `sexpr-js-runtime-plan.md`)
