# Sojorn Admin Panel — Comprehensive System Documentation

> Last updated: February 6, 2026

---

## Table of Contents

1. [Overview](#overview)
2. [Architecture](#architecture)
3. [Authentication & Security](#authentication--security)
4. [Server Deployment](#server-deployment)
5. [Frontend (Next.js)](#frontend-nextjs)
6. [Backend API Routes](#backend-api-routes)
7. [Database Schema](#database-schema)
8. [Feature Reference](#feature-reference)
9. [Environment Variables](#environment-variables)
10. [Troubleshooting](#troubleshooting)

---

## Overview

The Sojorn Admin Panel is an internal tool for platform administrators to manage users, moderate content, review appeals, configure the feed algorithm, and monitor system health. It is a standalone Next.js 14 application that communicates with the existing Sojorn Go backend via a dedicated set of admin API endpoints.

**Key characteristics:**
- Separate frontend deployment from the main Flutter app
- Role-based access — only users with `role = 'admin'` in the `profiles` table can log in
- All admin actions are logged to the `audit_log` table
- Invisible Cloudflare Turnstile bot protection on login
- JWT authentication with 24-hour token expiry

---

## Architecture

```
┌─────────────────────┐     HTTPS      ┌──────────────────────┐
│  Browser            │ ◄────────────► │  Nginx               │
│  admin.sojorn.net   │                │  (reverse proxy)     │
└─────────────────────┘                └──────┬───────────────┘
                                              │
                           ┌──────────────────┼──────────────────┐
                           │ port 3002        │ port 8080        │
                           ▼                  ▼                  │
                  ┌─────────────────┐ ┌─────────────────┐       │
                  │ Next.js 14      │ │ Go Backend      │       │
                  │ (sojorn-admin)  │ │ (sojorn-api)    │       │
                  │ SSR + Static    │ │ Gin framework   │       │
                  └─────────────────┘ └────────┬────────┘       │
                                               │                │
                                      ┌────────▼────────┐       │
                                      │ PostgreSQL      │       │
                                      │ sojorn database │       │
                                      └─────────────────┘       │
                                                                │
                                      ┌─────────────────┐       │
                                      │ Cloudflare R2   │───────┘
                                      │ (media storage) │
                                      └─────────────────┘
```

### Tech Stack

| Component | Technology |
|-----------|-----------|
| Frontend | Next.js 14, TypeScript, TailwindCSS, Recharts, Lucide icons |
| Backend | Go 1.21+, Gin framework, pgx (PostgreSQL driver) |
| Database | PostgreSQL 15+ |
| Process management | systemd |
| Reverse proxy | Nginx |
| Bot protection | Cloudflare Turnstile (invisible mode) |
| Auth | JWT (HS256), bcrypt password hashing |

---

## Authentication & Security

### Login Flow

1. User enters email + password on `/login`
2. Cloudflare Turnstile invisible widget generates a token in the background
3. Frontend sends `POST /api/v1/admin/login` with `{ email, password, turnstile_token }`
4. Backend verifies Turnstile token with Cloudflare API
5. Backend checks `users` table for valid credentials (bcrypt)
6. Backend verifies account status is `active`
7. Backend checks `profiles.role = 'admin'`
8. Returns JWT (`access_token`) with 24-hour expiry + user profile data
9. Token stored in `localStorage` as `admin_token`

### Middleware Chain (Protected Routes)

All `/api/v1/admin/*` routes (except `/login`) pass through:

1. **AuthMiddleware** — Validates JWT, extracts `user_id` from `sub` claim, sets it in Gin context
2. **AdminMiddleware** — Queries `profiles.role` for the authenticated user, rejects non-admin users with 403

### Security Measures

- **Invisible Turnstile** — Blocks automated login attacks without user friction
- **Graceful degradation** — If `TURNSTILE_SECRET` is empty, verification is skipped (dev mode)
- **bcrypt** — Passwords hashed with bcrypt (default cost)
- **JWT expiry** — Admin tokens expire after 24 hours (vs 7 days for regular users)
- **Auto-logout** — Frontend redirects to `/login` on any 401 response
- **Audit logging** — Status changes, post deletions, and moderation actions are logged

### Granting Admin Access

```sql
UPDATE profiles SET role = 'admin' WHERE handle = 'your_handle';
```

Valid roles: `user`, `moderator`, `admin`

---

## Server Deployment

### Services

| Service | systemd unit | Port | Binary/Entry |
|---------|-------------|------|-------------|
| Go API | `sojorn-api` | 8080 | `/opt/sojorn/bin/api` |
| Admin Frontend | `sojorn-admin` | 3002 | `node .../next start --port 3002` |

### File Locations on Server

```
/opt/sojorn/
├── .env                          # Shared environment variables
├── bin/
│   └── api                       # Compiled Go binary
├── go-backend/                   # Go source code
│   ├── cmd/api/main.go
│   ├── internal/
│   │   ├── handlers/admin_handler.go
│   │   ├── middleware/admin.go
│   │   └── ...
│   └── .env                      # Symlink or copy of /opt/sojorn/.env
├── admin/                        # Next.js admin frontend
│   ├── .env.local                # Frontend env vars
│   ├── .next/                    # Build output
│   ├── node_modules/
│   ├── src/
│   │   ├── app/                  # Page routes
│   │   └── lib/                  # API client, auth context
│   └── package.json
└── firebase-service-account.json
```

### systemd Service Files

**`/etc/systemd/system/sojorn-admin.service`**:
```ini
[Unit]
Description=Sojorn Admin Panel
After=network.target

[Service]
Type=simple
User=patrick
Group=patrick
WorkingDirectory=/opt/sojorn/admin
ExecStart=/usr/bin/node /opt/sojorn/admin/node_modules/next/dist/bin/next start --port 3002
Restart=on-failure
RestartSec=30
StartLimitIntervalSec=120
StartLimitBurst=3
Environment=NODE_ENV=production
Environment=NEXT_PUBLIC_API_URL=https://api.sojorn.net

[Install]
WantedBy=multi-user.target
```

### Nginx Configuration

**`/etc/nginx/sites-available/sojorn-admin`**:
```nginx
server {
    listen 80;
    server_name admin.sojorn.net;

    location / {
        proxy_pass http://127.0.0.1:3002;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        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_cache_bypass $http_upgrade;
    }
}
```

### Common Operations

```bash
# Rebuild and deploy Go backend
cd /opt/sojorn/go-backend
go build -ldflags='-s -w' -o /opt/sojorn/bin/api ./cmd/api/main.go
sudo systemctl restart sojorn-api

# Rebuild and deploy admin frontend
cd /opt/sojorn/admin
npx next build
sudo systemctl restart sojorn-admin

# View logs
sudo journalctl -u sojorn-admin -f
sudo journalctl -u sojorn-api -f

# Check service status
sudo systemctl status sojorn-admin
sudo systemctl status sojorn-api

# SSL certificate (after DNS A record is pointed)
sudo certbot --nginx -d admin.sojorn.net
```

---

## Frontend (Next.js)

### Pages

| Route | File | Description |
|-------|------|-------------|
| `/` | `app/page.tsx` | Dashboard with stats cards and growth charts |
| `/login` | `app/login/page.tsx` | Admin login with invisible Turnstile |
| `/users` | `app/users/page.tsx` | User list with search, filter by status/role |
| `/users/[id]` | `app/users/[id]/page.tsx` | User detail — profile, stats, admin actions |
| `/posts` | `app/posts/page.tsx` | Post list with search, filter by status |
| `/posts/[id]` | `app/posts/[id]/page.tsx` | Post detail — content, media, moderation flags, admin actions |
| `/moderation` | `app/moderation/page.tsx` | Moderation queue — AI-flagged content pending review |
| `/appeals` | `app/appeals/page.tsx` | User appeals against violations |
| `/reports` | `app/reports/page.tsx` | User-submitted reports |
| `/algorithm` | `app/algorithm/page.tsx` | Feed algorithm & moderation threshold tuning |
| `/categories` | `app/categories/page.tsx` | Category management — create, edit, toggle sensitive |
| `/system` | `app/system/page.tsx` | System health, DB stats, audit log |
| `/settings` | `app/settings/page.tsx` | Admin session info, API URL override |

### Key Libraries

| Library | Source |
|---------|--------|
| `api.ts` | `src/lib/api.ts` — Singleton API client with JWT token management |
| `auth.tsx` | `src/lib/auth.tsx` — React context provider for auth state |

### API Client (`src/lib/api.ts`)

The `ApiClient` class provides typed methods for every admin API endpoint. Key behaviors:

- **Token management** — Stored in `localStorage` as `admin_token`, attached as `Bearer` header
- **Auto-logout** — Any 401 response clears token and redirects to `/login`
- **Base URL** — Configurable via `NEXT_PUBLIC_API_URL` environment variable
- **Error handling** — Throws `Error` with server-provided error message

### Auth Context (`src/lib/auth.tsx`)

- Wraps app in `AuthProvider`
- On mount, validates existing token by calling `getDashboardStats()`
- Provides `login()`, `logout()`, `isAuthenticated`, `isLoading`, `user`

---

## Backend API Routes

All routes prefixed with `/api/v1/admin/`.

### Authentication (no middleware)

| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `POST` | `/login` | `AdminLogin` | Email/password login with Turnstile verification |

### Dashboard (requires auth + admin)

| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `GET` | `/dashboard` | `GetDashboardStats` | User/post/moderation/appeal/report counts |
| `GET` | `/growth?days=30` | `GetGrowthStats` | Daily user & post creation counts for charts |

### User Management

| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `GET` | `/users?limit=50&offset=0&search=&status=&role=` | `ListUsers` | Paginated user list with filters |
| `GET` | `/users/:id` | `GetUser` | Full user profile with follower/post/violation counts |
| `PATCH` | `/users/:id/status` | `UpdateUserStatus` | Set status: `active`, `suspended`, `banned`, `deactivated` |
| `PATCH` | `/users/:id/role` | `UpdateUserRole` | Set role: `user`, `moderator`, `admin` |
| `PATCH` | `/users/:id/verification` | `UpdateUserVerification` | Toggle `is_official` and `is_verified` flags |
| `POST` | `/users/:id/reset-strikes` | `ResetUserStrikes` | Reset violation strike counter to 0 |

### Post Management

| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `GET` | `/posts?limit=50&offset=0&search=&status=&author_id=` | `ListPosts` | Paginated post list with filters |
| `GET` | `/posts/:id` | `GetPost` | Full post detail with moderation flags |
| `PATCH` | `/posts/:id/status` | `UpdatePostStatus` | Set status: `active`, `flagged`, `removed` |
| `DELETE` | `/posts/:id` | `DeletePost` | Soft-delete (sets `deleted_at` + status `removed`) |

### Moderation Queue

| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `GET` | `/moderation?limit=50&offset=0&status=pending` | `GetModerationQueue` | AI-flagged content awaiting review |
| `PATCH` | `/moderation/:id/review` | `ReviewModerationFlag` | Actions: `approve`, `dismiss`, `remove_content`, `ban_user` |

### Appeals

| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `GET` | `/appeals?limit=50&offset=0&status=pending` | `ListAppeals` | User appeals with violation details |
| `PATCH` | `/appeals/:id/review` | `ReviewAppeal` | Decision: `approved` or `rejected`, optional content restore |

### Reports

| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `GET` | `/reports?limit=50&offset=0&status=pending` | `ListReports` | User-submitted reports |
| `PATCH` | `/reports/:id` | `UpdateReportStatus` | Set status: `reviewed`, `dismissed`, `actioned` |

### Algorithm & Feed Config

| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `GET` | `/algorithm` | `GetAlgorithmConfig` | All key-value config pairs |
| `PUT` | `/algorithm` | `UpdateAlgorithmConfig` | Upsert a config `{ key, value }` |

### Categories

| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `GET` | `/categories` | `ListCategories` | All content categories |
| `POST` | `/categories` | `CreateCategory` | Create `{ slug, name, description?, is_sensitive? }` |
| `PATCH` | `/categories/:id` | `UpdateCategory` | Update name, description, or sensitive flag |

### System

| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `GET` | `/health` | `GetSystemHealth` | DB ping, latency, connection pool stats, DB size |
| `GET` | `/audit-log?limit=50&offset=0` | `GetAuditLog` | Admin action history with actor handles |

---

## Database Schema

### Tables Created by Admin Migration

**`algorithm_config`** — Key-value store for feed and moderation tuning:

| Column | Type | Description |
|--------|------|-------------|
| `key` | `TEXT PRIMARY KEY` | Config identifier |
| `value` | `TEXT NOT NULL` | Config value |
| `description` | `TEXT` | Human-readable description |
| `updated_at` | `TIMESTAMPTZ` | Last modification time |

Default seed values:

| Key | Default | Description |
|-----|---------|-------------|
| `feed_recency_weight` | `0.4` | Weight for post recency in feed ranking |
| `feed_engagement_weight` | `0.3` | Weight for engagement metrics |
| `feed_harmony_weight` | `0.2` | Weight for author harmony/trust score |
| `feed_diversity_weight` | `0.1` | Weight for content diversity |
| `moderation_auto_flag_threshold` | `0.7` | AI score threshold for auto-flagging |
| `moderation_auto_remove_threshold` | `0.95` | AI score threshold for auto-removal |
| `moderation_greed_keyword_threshold` | `0.7` | Spam/greed detection threshold |
| `feed_max_posts_per_author` | `3` | Max posts from same author per feed page |
| `feed_boost_mutual_follow` | `1.5` | Boost multiplier for mutual follows |
| `feed_beacon_boost` | `1.2` | Boost multiplier for beacon posts |

**`audit_log`** — Admin action history:

| Column | Type | Description |
|--------|------|-------------|
| `id` | `UUID PRIMARY KEY` | Unique entry ID |
| `actor_id` | `UUID` | Admin who performed the action |
| `action` | `TEXT NOT NULL` | Action type (e.g., `post_status_change`, `admin_delete_post`) |
| `target_type` | `TEXT NOT NULL` | Entity type: `user`, `post`, `comment`, `appeal`, `report`, `config` |
| `target_id` | `UUID` | ID of the affected entity |
| `details` | `TEXT` | JSON string with action-specific metadata |
| `created_at` | `TIMESTAMPTZ` | Timestamp |

### Columns Ensured by Migration

The migration ensures these columns exist (added if missing):

| Table | Column | Type | Default |
|-------|--------|------|---------|
| `profiles` | `role` | `TEXT` | `'user'` |
| `profiles` | `is_verified` | `BOOLEAN` | `FALSE` |
| `profiles` | `is_private` | `BOOLEAN` | `FALSE` |
| `users` | `status` | `TEXT` | `'active'` |
| `users` | `last_login` | `TIMESTAMPTZ` | `NULL` |

### Pre-existing Tables Used by Admin

| Table | Admin Usage |
|-------|-------------|
| `users` | Login validation, status management, growth stats |
| `profiles` | Role checks, user details, verification flags |
| `posts` | Content listing, status changes, deletion |
| `comments` | Moderation flag targets, appeal content restoration |
| `moderation_flags` | Moderation queue — AI-generated flags with scores |
| `user_violations` | Violation records linked to moderation flags |
| `user_appeals` | Appeal records linked to violations |
| `user_status_history` | Log of admin-initiated status changes |
| `reports` | User-submitted reports of other users/content |
| `categories` | Content categories managed by admins |
| `follows` | Follower/following counts on user detail page |

---

## Feature Reference

### Dashboard

Displays real-time aggregate stats:
- **Users**: total, active, suspended, banned, new today
- **Posts**: total, active, flagged, removed, new today
- **Moderation**: pending flags, reviewed flags
- **Appeals**: pending, approved, rejected
- **Reports**: pending count
- **Growth charts**: Daily user & post registrations (configurable 7/30/90 day window)

### Moderation Workflow

1. **AI flagging** — Posts/comments are automatically analyzed by the `ModerationService` using OpenAI + Google Vision
2. **Three Poisons Score** — Content is scored on Hate, Greed, Delusion dimensions
3. **Auto-flag** — Content exceeding `moderation_auto_flag_threshold` is flagged for review
4. **Admin review** — Admin sees flagged content in the moderation queue with scores
5. **Actions available**:
   - **Approve** — Content is fine, dismiss the flag
   - **Dismiss** — Same as approve (flag was a false positive)
   - **Remove content** — Soft-delete the post/comment
   - **Ban user** — Ban the author and action the flag

### Appeal Workflow

1. User receives a violation (triggered by moderation)
2. User submits an appeal with reason and context
3. Appeal appears in admin panel with violation details, AI scores, and original content
4. Admin reviews and decides:
   - **Approve** — Optionally restore the removed content
   - **Reject** — Violation stands, include written reasoning (min 5 chars)

### User Management Actions

- **Change status**: `active` ↔ `suspended` ↔ `banned` ↔ `deactivated` (requires reason)
- **Change role**: `user` ↔ `moderator` ↔ `admin`
- **Toggle verification**: `is_official` and `is_verified` badges
- **Reset strikes**: Clear the violation counter

---

## Environment Variables

### Frontend (`/opt/sojorn/admin/.env.local`)

| Variable | Value | Description |
|----------|-------|-------------|
| `NEXT_PUBLIC_API_URL` | `https://api.sojorn.net` | Go backend base URL |
| `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | `0x4AAAAAAC...` | Cloudflare Turnstile site key (invisible mode) |

### Backend (`/opt/sojorn/.env`)

The admin system uses these existing environment variables:

| Variable | Description |
|----------|-------------|
| `DATABASE_URL` | PostgreSQL connection string |
| `JWT_SECRET` | Secret for signing/verifying JWT tokens |
| `TURNSTILE_SECRET` | Cloudflare Turnstile server-side verification key |
| `PORT` | API server port (default: `8080`) |

---

## Troubleshooting

### Admin login returns "Admin access required"

The user's profile doesn't have `role = 'admin'`. Fix:
```sql
UPDATE profiles SET role = 'admin' WHERE handle = 'your_handle';
```

### Admin login returns "Invalid credentials"

- Verify the email matches what's in the `users` table
- Password is validated against `encrypted_password` via bcrypt
- Check the user's `status` is `active` (not `pending`, `suspended`, or `banned`)

### Admin login returns "Security verification failed"

Cloudflare Turnstile rejected the request. Possible causes:
- `TURNSTILE_SECRET` in backend `.env` doesn't match the site key in frontend `.env.local`
- The Turnstile widget hostname doesn't include `admin.sojorn.net` in Cloudflare dashboard
- Bot or automated request without a valid Turnstile token

### sojorn-admin service won't start

```bash
# Check logs
sudo journalctl -u sojorn-admin -n 50 --no-pager

# Check if port 3002 is in use
ss -tlnp | grep 3002

# If port is taken, find and kill the process
sudo fuser -k 3002/tcp
sudo systemctl restart sojorn-admin
```

### sojorn-api panics on startup

Check for duplicate route registrations in `cmd/api/main.go`. The Go backend will panic if two routes resolve to the same path (e.g., legacy routes conflicting with admin group routes).

### Frontend shows "Loading..." indefinitely

- Check browser console for network errors
- Verify `NEXT_PUBLIC_API_URL` in `.env.local` is correct and reachable
- Ensure the Go API is running: `sudo systemctl status sojorn-api`
- Check CORS — the backend must allow the admin domain in `CORS_ORIGINS`

### Database migration errors

Run the migration manually:
```bash
export PGPASSWORD=your_db_password
psql -U postgres -h localhost -d sojorn -f /opt/sojorn/go-backend/internal/database/migrations/20260206000001_admin_panel_tables.up.sql
```

### PM2 conflicts

Port 3001 is used by another PM2-managed site on this server. The admin panel uses port **3002** to avoid conflicts. Do not change it to 3001.
