# ClassicsLens — Deployment Guide

## Overview

ClassicsLens runs as a Docker Compose stack on a single VPS. The stack has three containers:

| Container | Purpose | Port |
|-----------|---------|------|
| `lector-app` | Node.js/Express + React SPA | 5001 (host) → 5000 (internal) |
| `lector-postgres` | PostgreSQL 16, all user data | Internal only |
| `morpheus-api` | Python Morpheus sidecar | 5100 (internal only) |

nginx reverse proxy (`n8n-nginx-1`) handles SSL termination and proxies to `lector-app`.

---

## Server

| Item | Value |
|------|-------|
| VPS IP | `93.188.166.21` |
| App directory | `/var/lib/docker/volumes/openclaw_workspace/_data/classicslens` |
| Domain | `classicslens.com` |
| DNS | Cloudflare (A records → VPS IP, proxied) |
| SSL | Let's Encrypt via certbot, auto-renewal |
| nginx config | `/root/n8n/nginx/nginx.conf` |

---

## Quick Reference — Common Operations

### Deploy code changes

```bash
cd /var/lib/docker/volumes/openclaw_workspace/_data/classicslens
git pull
docker compose -p lector build && docker compose -p lector up -d
```

### Check app logs

```bash
docker logs lector-app -f
docker logs lector-postgres -f
```

### Restart without rebuild

```bash
cd /var/lib/docker/volumes/openclaw_workspace/_data/classicslens
docker compose -p lector up -d
```

### Connect to the database

```bash
docker exec -it lector-postgres psql -U lector -d lector
```

### Run the full test suite

```bash
cd /var/lib/docker/volumes/openclaw_workspace/_data/classicslens
npx playwright test --config=playwright.config.ts
```

Tests run against `https://classicslens.com`. The test user is `playwright-test@classicslens.com` (tier_override=pro — never remove this or analyze tests will fail).

---

## Environment Variables

Secrets live in `.env` at the repo root (gitignored). `docker-compose.yml` references them via `${VARIABLE}`. The `.env` must include `COMPOSE_PROJECT_NAME=lector` so docker compose uses the correct project name when run from the workspace directory.

| Variable | Required | Description |
|----------|----------|-------------|
| `DATABASE_URL` | Yes | PostgreSQL connection string |
| `SESSION_SECRET` | Yes | Long random hex string for signing cookies |
| `AUTH_ENABLED` | Yes | `true` to require login |
| `GOOGLE_CLIENT_ID` | Yes | From Google Cloud Console |
| `GOOGLE_CLIENT_SECRET` | Yes | From Google Cloud Console |
| `GOOGLE_REDIRECT_URI` | Yes | `https://classicslens.com/api/auth/google/callback` |
| `APP_URL` | Yes | `https://classicslens.com` (used in emails and Stripe redirects) |
| `RESEND_API_KEY` | Yes | From Resend dashboard — for password reset and invite emails |
| `STRIPE_SECRET_KEY` | Yes | From Stripe dashboard |
| `STRIPE_WEBHOOK_SECRET` | Yes | From Stripe webhook endpoint config |
| `STRIPE_PRO_PRICE_ID` | Yes | Stripe Price ID for Pro tier |
| `STRIPE_INSTRUCTOR_PRICE_ID` | Yes | Stripe Price ID for Instructor tier |
| `STRIPE_ACADEMIC_PRICE_ID` | No | Stripe Price ID for Academic tier — not yet provisioned, leave blank until academic subscriptions are activated |
| `POSTGRES_PASSWORD` | Yes | PostgreSQL password (also used to build `DATABASE_URL`) |
| `ADMIN_EMAIL` | Yes | Email address that gets admin panel access |
| `MORPHOLOGY_DB_PATH` | No | Defaults to `/app/data/morphology.db` |
| `MORPHEUS_API_URL` | No | Defaults to `http://morpheus-api:5100` |
| `PORT` | No | Defaults to `5000` |

---

## nginx Configuration

nginx is the shared `n8n-nginx-1` container. Config at `/root/n8n/nginx/nginx.conf`.

**Important**: `nginx -s reload` does NOT pick up bind-mount config changes. Always restart the container:

```bash
docker stop n8n-nginx-1 && docker start n8n-nginx-1
```

### classicslens.com blocks

```nginx
# HTTP — ACME challenge + redirect
server {
    listen 80;
    server_name classicslens.com www.classicslens.com;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://classicslens.com$request_uri;
    }
}

# lector.nerdbox.com — permanent redirect to classicslens.com
server {
    listen 443 ssl;
    server_name lector.nerdbox.com;
    ssl_certificate     /etc/letsencrypt/live/lector.nerdbox.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/lector.nerdbox.com/privkey.pem;
    return 301 https://classicslens.com$request_uri;
}

# HTTPS — proxy to lector-app
server {
    listen 443 ssl;
    server_name classicslens.com www.classicslens.com;

    ssl_certificate     /etc/letsencrypt/live/classicslens.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/classicslens.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 10m;

    location / {
        proxy_pass http://172.19.0.1:5001;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 60s;
    }
}
```

---

## SSL Certificates

Certificates issued via Let's Encrypt. Renewal handled by the `n8n-certbot-1` container on a cron schedule.

To issue a new certificate for a new domain:

```bash
docker run --rm \
  -v /etc/letsencrypt:/etc/letsencrypt \
  -v /var/www/certbot:/var/www/certbot \
  certbot/certbot certonly --webroot \
  -w /var/www/certbot \
  -d classicslens.com \
  -d www.classicslens.com \
  --email admin@classicslens.com \
  --agree-tos --no-eff-email
```

DNS A records must be live and the HTTP ACME block in nginx must be active before running certbot.

---

## Database

### Schema

All tables are in the `public` schema of the `lector` database. Key tables:

| Table | Purpose |
|-------|---------|
| `users` | Accounts — email, auth provider, Stripe IDs, tier, instructor_student_limit |
| `user_settings` | Per-user language mix, POS coloring, theme |
| `user_state` | Day counter, streak, last completion date |
| `passages` | 9,755 curated passages with translations |
| `passage_word_occurrences` | Pre-indexed word positions + lemma links (~712K rows) |
| `passage_notes` | Passage-level notes (currently empty; reserved) |
| `passage_questions` | Comprehension questions per passage |
| `passage_context` | AI-generated context cards (historical notes, tags) |
| `review_items` | SM-2 SRS state per user per word |
| `lemma_glosses` | AI-generated usage glosses per lemma |
| `paradigms` | Full conjugation/declension tables (~2.6M rows total) |
| `instructor_classes` | One row per class |
| `class_students` | Roster with status (pending/active/removed) |
| `assigned_passages` | Assignments linking classes to passages or custom texts |
| `custom_texts` | Instructor-uploaded texts |
| `custom_text_completions` | Completion tracking for custom text assignments |
| `passage_completions` | Completion tracking for corpus passage assignments |
| `teams` | Academic tier team accounts |
| `team_members` | Team membership |
| `promo_codes` | Promo code definitions |
| `promo_redemptions` | Who redeemed which code |
| `parse_quota_log` | Daily parse quota tracking for free accounts |
| `password_reset_tokens` | One-time password reset tokens (1hr TTL) |
| `sessions` | connect-pg-simple session store |

### Useful queries

```sql
-- All users with their effective tier
SELECT email, display_name,
       COALESCE(tier_override, subscription_tier) AS effective_tier,
       created_at
FROM users ORDER BY created_at DESC;

-- Instructor accounts and their student counts
SELECT u.email, u.instructor_student_limit,
       COUNT(DISTINCT cs.student_id) FILTER (WHERE cs.status = 'active') AS active_students
FROM users u
LEFT JOIN instructor_classes ic ON ic.instructor_id = u.id
LEFT JOIN class_students cs ON cs.class_id = ic.id
WHERE COALESCE(u.tier_override, u.subscription_tier) IN ('instructor', 'academic')
GROUP BY u.id, u.email, u.instructor_student_limit;

-- Reset today's parse quota for a user
DELETE FROM parse_quota_log
WHERE user_id = (SELECT id FROM users WHERE email = 'user@example.com')
  AND log_date = CURRENT_DATE;

-- Give a user a tier override (e.g. for testing)
UPDATE users SET tier_override = 'pro' WHERE email = 'user@example.com';

-- Remove tier override (revert to Stripe subscription)
UPDATE users SET tier_override = NULL WHERE email = 'user@example.com';
```

### Backups

PostgreSQL data lives in the `lector_pg_data` Docker volume. Back up with:

```bash
docker exec lector-postgres pg_dump -U lector lector | gzip > backup-$(date +%Y%m%d).sql.gz
```

Restore:

```bash
gunzip -c backup-20260407.sql.gz | docker exec -i lector-postgres psql -U lector lector
```

---

## Google OAuth Setup

In [Google Cloud Console](https://console.cloud.google.com/):

1. Create or select a project
2. APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client ID
3. Application type: **Web application**
4. Authorized redirect URIs: `https://classicslens.com/api/auth/google/callback`
5. Copy Client ID and Client Secret to `.env`

If the redirect URI changes (e.g. new domain), update it in the Console and in `GOOGLE_REDIRECT_URI` in `.env`, then rebuild: `docker compose build && docker compose up -d`.

---

## Stripe Setup

1. Create products and prices in the Stripe dashboard for each tier
2. Copy Price IDs (`price_...`) to `.env` as `STRIPE_PRO_PRICE_ID`, `STRIPE_INSTRUCTOR_PRICE_ID`, `STRIPE_ACADEMIC_PRICE_ID`
3. Create a webhook endpoint pointing to `https://classicslens.com/api/billing/webhook`
4. Subscribe to events: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`
5. Copy the webhook signing secret to `STRIPE_WEBHOOK_SECRET`

---

## Morphology Database

The SQLite morphology database (`morphology.db`) is not committed to the repo (~500MB). It is volume-mounted read-only and contains no user data.

It lives at `/var/lib/docker/volumes/lector_data/_data/morphology.db` on the host.

To rebuild from scratch (takes 30–90 minutes; requires the Morpheus Docker image):

```bash
cd /var/lib/docker/volumes/openclaw_workspace/_data/classicslens

# 1. Extract headwords from LSJ and Lewis & Short
python3 scripts/extract-headwords.py

# 2. Run all headwords through Morpheus (long-running; use screen or nohup)
screen -S morphdb
python3 scripts/build-morphology-db.py
# Ctrl+A, D to detach; screen -r morphdb to reattach

# If interrupted, resume safely:
python3 scripts/build-morphology-db.py --resume

# 3. Import shortdefs from Logeion
python3 scripts/import-shortdefs.py

# 4. Copy into volume
cp data/morphology.db /var/lib/docker/volumes/lector_data/_data/
```

The app logs morphology DB stats on startup:
```
[morpheus-db] loaded 222000 forms, 128000 lemmas
```

If the database is absent, the app falls back to the built-in in-memory inflection tables (limited coverage, ~2,700 forms) and logs a warning.

---

## Updating Passage Data

Scripts are run inside the container using `tsx`:

```bash
# Copy script into container
docker cp scripts/my-script.ts lector-app:/app/my-script.ts

# Run it
docker exec lector-app node /app/node_modules/tsx/dist/cli.mjs /app/my-script.ts
```

Note: `npx tsx` is not in PATH; use the full path to the tsx binary above.

---

## Playwright Tests

Tests run against the live site (`https://classicslens.com`).

```bash
cd /var/lib/docker/volumes/openclaw_workspace/_data/classicslens
npx playwright test --config=playwright.config.ts
```

The test user `playwright-test@classicslens.com` must have `tier_override = 'pro'` in the database (set permanently; don't remove). Without it, analyze tests fail on the parse quota.

To run a single spec:
```bash
npx playwright test tests/lector/04-analyze.spec.ts --config=playwright.config.ts
```

To re-run auth setup (if the session state expires):
```bash
npx playwright test tests/lector/auth.setup.ts --config=playwright.config.ts
```

Chromium headless shell is installed at `/root/.cache/ms-playwright/`. webkit is NOT installed — mobile tests use Pixel 5 chromium viewport.

---

## Development Utilities

### server/diag.ts

A one-off debug script for spot-checking the Greek paradigm generator (verb forms and noun declensions). Not wired into any build or test pipeline — run manually with tsx when you need to sanity-check paradigm output:

```bash
cd /var/lib/docker/volumes/openclaw_workspace/_data/classicslens
npx tsx server/diag.ts
```

---

## Adding a New Domain or Renaming

If the domain changes:

1. Update DNS A records
2. Issue new Let's Encrypt cert (see SSL section above)
3. Update nginx config with new server_name and cert paths; restart nginx
4. Update `APP_URL` and `GOOGLE_REDIRECT_URI` in `.env`
5. Update `GOOGLE_REDIRECT_URI` in Google Cloud Console
6. `docker compose build && docker compose up -d`
7. Update Stripe webhook endpoint URL
