# snakkimo-API REST API server for the snakkimo project. Connects to a PostgreSQL database and stores picture assets on Hetzner Object Storage. - **Base URL:** `https://hyggecraftery.com/api/snakkimo` - **Auth:** All `/api/*` routes require `Authorization: Bearer ` - **Public routes:** `GET /health`, `POST /auth/register`, `POST /auth/login` --- ## Authentication Two token types are accepted: ### 1. Static dev/admin tokens Configured via the `API_TOKENS` environment variable (comma-separated). These bypass role checks and are used for server-to-server or CMT admin access. ``` Authorization: Bearer dev_ccfd6fd1... ``` ### 2. JWT tokens (end-users) Issued by `POST /auth/register` and `POST /auth/login`. End-users with role `end-user` receive a **403** on all `/api/*` routes — JWT auth is currently used to gate app access only. ``` Authorization: Bearer eyJhbGci... ``` --- ## Environment Variables | Variable | Description | |---|---| | `DB_HOST` | PostgreSQL hostname (Coolify internal) | | `DB_PORT` | PostgreSQL port (default: `5432`) | | `DB_NAME` | Database name | | `DB_USER` | Database user | | `DB_PASSWORD` | Database password | | `DB_SSL` | `true` or `false` | | `PORT` | API server port (default: `3000`) | | `API_TOKENS` | Comma-separated static Bearer tokens | | `JWT_SECRET` | Secret for signing JWTs (long random string) | | `JWT_EXPIRES_IN` | JWT lifetime, e.g. `7d` | | `S3_ACCESS_KEY` | Hetzner Object Storage access key | | `S3_SECRET_KEY` | Hetzner Object Storage secret key | --- ## Storage **Provider:** Hetzner Object Storage (S3-compatible) **Endpoint:** `https://fsn1.your-objectstorage.com` **Bucket:** `snakkimo` **Public base URL:** `https://snakkimo.fsn1.your-objectstorage.com` Files are stored under `pictures//.`. Deleting a picture via the API also deletes the file from the bucket. --- ## Database Schema **Host:** Coolify internal network · **Database:** `snakkimo` · **Schema:** `public` All tables have `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` and `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` with an auto-update trigger unless noted otherwise. ### Relations overview ``` pictures ──── word_pictures ──────────────────── words ──── word_categories ──── categories │ │ └──── object_pictures ──── objects ──── object_words │ object_pairs ──── pairs ──── questions │ positive_statement_id ──┐ negative_statement_id ──┴── statements ──── statement_positive_words ──┐ └─── statement_negative_words ──┴── words users ──── languages (native) users_public ──── user_names └── languages (native + target) ``` --- ### `pictures` | Column | Type | Default | Description | |---|---|---|---| | `id` | UUID PK | `gen_random_uuid()` | | | `status` | VARCHAR(20) | `uploaded` | `uploaded` · `published` · `blocked` | | `blocked_reason` | VARCHAR(20) | null | `regenerate` · `not_to_use` | | `generation_prompt` | TEXT | null | | | `generation_timestamp` | TIMESTAMPTZ | null | | | `generation_duration_s` | NUMERIC(10,3) | null | | | `published_timestamp` | TIMESTAMPTZ | null | | | `blocked_timestamp` | TIMESTAMPTZ | null | | | `blurhash` | TEXT | null | | | `picture_link` | TEXT | null | Public Hetzner URL, set on upload | | `design` | TEXT | null | Design category | | `objects_created` | BOOLEAN | `false` | Set to true once all objects have been drawn | | `objects_created_at` | TIMESTAMPTZ | null | Auto-set when `objects_created` → true | **Junctions:** `word_pictures`, `object_pictures` --- ### `words` | Column | Type | Default | Description | |---|---|---|---| | `id` | UUID PK | `gen_random_uuid()` | | | `titel_de` | TEXT | null | German | | `titel_en` | TEXT | null | English | | `titel_sv` | TEXT | null | Swedish (ISO 639-1: `sv`) | | `status` | VARCHAR(20) | `requested` | `requested` · `translated` · `generated` · `blocked` · `published` | | `difficulty_level` | SMALLINT | null | 1–50 | | `requested_at` | TIMESTAMPTZ | null | | | `published_at` | TIMESTAMPTZ | null | Auto-set on status → `published` | | `blocked_at` | TIMESTAMPTZ | null | Auto-set on status → `blocked` | **Junctions:** `word_pictures`, `word_categories`, `object_words`, `statement_positive_words`, `statement_negative_words` --- ### `categories` | Column | Type | Default | Description | |---|---|---|---| | `id` | UUID PK | `gen_random_uuid()` | | | `titel_de` | TEXT | null | | | `titel_en` | TEXT | null | | | `titel_sv` | TEXT | null | | | `status` | VARCHAR(20) | `requested` | `requested` · `blocked` · `published` | | `difficulty_level` | SMALLINT | null | 1–50 | | `published_at` | TIMESTAMPTZ | null | | | `blocked_at` | TIMESTAMPTZ | null | | **Junctions:** `word_categories` --- ### `questions` | Column | Type | Default | Description | |---|---|---|---| | `id` | UUID PK | `gen_random_uuid()` | | | `status` | VARCHAR(20) | `draft` | `draft` · `blocked` · `published` | | `sentence_de` | TEXT | null | German question sentence | | `sentence_en` | TEXT | null | English | | `sentence_sv` | TEXT | null | Swedish | | `blocked_topic` | TEXT | null | | | `published_at` | TIMESTAMPTZ | null | | | `blocked_at` | TIMESTAMPTZ | null | | Sentence text may contain `{{uuid}}` placeholders referring to a `words.id` or `objects.id`. --- ### `statements` | Column | Type | Default | Description | |---|---|---|---| | `id` | UUID PK | `gen_random_uuid()` | | | `status` | VARCHAR(20) | `draft` | `draft` · `blocked` · `published` | | `positive_sentence_de` | TEXT | null | Positive form, German | | `positive_sentence_en` | TEXT | null | | | `positive_sentence_sv` | TEXT | null | | | `negative_sentence_de` | TEXT | null | Negative form, German | | `negative_sentence_en` | TEXT | null | | | `negative_sentence_sv` | TEXT | null | | | `answer` | BOOLEAN | null | For yes/no pairs: `true` = Ja, `false` = Nein, `null` = open | | `blocked_topic` | TEXT | null | | | `published_at` | TIMESTAMPTZ | null | | | `blocked_at` | TIMESTAMPTZ | null | | Sentence text may contain `{{uuid}}` placeholders. GET `/api/statements/:id` also returns `positive_word_ids[]` and `negative_word_ids[]`. **Junctions:** `statement_positive_words`, `statement_negative_words` --- ### `pairs` | Column | Type | Default | Description | |---|---|---|---| | `id` | UUID PK | `gen_random_uuid()` | | | `status` | VARCHAR(20) | `draft` | `draft` · `blocked` · `published` | | `answer_type` | TEXT | — | `text` · `yes_no` · `question` · `word` (required) | | `difficulty_level` | SMALLINT | null | 1–50 | | `blocked_topic` | TEXT | null | | | `question_id` | UUID FK | null | → `questions.id` ON DELETE SET NULL | | `positive_statement_id` | UUID FK | null | → `statements.id` ON DELETE SET NULL | | `negative_statement_id` | UUID FK | null | → `statements.id` ON DELETE SET NULL | | `published_at` | TIMESTAMPTZ | null | | | `blocked_at` | TIMESTAMPTZ | null | | **answer_type semantics:** | Type | question | positive_statement | negative_statement | |---|---|---|---| | `text` | — | sentence (required) | — | | `yes_no` | optional sentence | `answer` field (true/false/null) | — | | `question` | sentence (required) | sentence (required) | sentence (optional) | | `word` | optional sentence | linked via `statement_positive_words` | linked via `statement_negative_words` | **Junctions:** `object_pairs` --- ### `objects` | Column | Type | Default | Description | |---|---|---|---| | `id` | UUID PK | `gen_random_uuid()` | | | `status` | VARCHAR(20) | `draft` | `draft` · `blocked` · `published` | | `selections` | JSONB | null | `[{points: [{x: 0-1, y: 0-1}, …]}]` — relative coordinates | | `notes` | TEXT | null | | | `blocked_topic` | TEXT | null | | | `published_at` | TIMESTAMPTZ | null | | | `blocked_at` | TIMESTAMPTZ | null | | **Junctions:** `object_words`, `object_pictures`, `object_pairs` --- ### `users` (CMT admin accounts) | Column | Type | Default | Description | |---|---|---|---| | `id` | UUID PK | `gen_random_uuid()` | | | `email` | TEXT UNIQUE | — | Lowercase-indexed | | `password_hash` | TEXT | — | bcrypt hash | | `role` | VARCHAR(20) | `end-user` | `end-user` · `admin` | | `is_active` | BOOLEAN | `true` | False = locked out | | `language_native_id` | UUID FK | null | → `languages.id` ON DELETE SET NULL | --- ### `languages` | Column | Type | Default | Description | |---|---|---|---| | `id` | UUID PK | `gen_random_uuid()` | | | `titel_de` | TEXT | null | | | `titel_en` | TEXT | null | | | `titel_sv` | TEXT | null | | | `short_en` | VARCHAR(10) | null | ISO 639-1 code, e.g. `de`, `en`, `sv` | | `status` | VARCHAR(20) | `draft` | `draft` · `blocked` · `published` | | `published_at` | TIMESTAMPTZ | null | | | `blocked_at` | TIMESTAMPTZ | null | | | `blocked_topic` | TEXT | null | | **Seeded:** German (`short_en = 'de'`) is automatically inserted on migration. **Default:** All users without a native language are assigned German on migration. --- ### `user_names` | Column | Type | Description | |---|---|---| | `id` | UUID PK | | | `username_lowercase` | TEXT UNIQUE | Lowercase version for uniqueness check | | `username` | TEXT | Display name (original casing) | --- ### `users_public` (app user profiles) | Column | Type | Description | |---|---|---| | `id` | UUID PK | | | `username_id` | UUID FK | → `user_names.id` ON DELETE SET NULL | | `language_native_id` | UUID FK | → `languages.id` ON DELETE SET NULL | | `language_target_id` | UUID FK | → `languages.id` ON DELETE SET NULL | --- ### `blocklist` | Column | Type | Description | |---|---|---| | `id` | UUID PK | | | `is_blocked` | BOOLEAN | `true` = active block | | `username` | TEXT | nullable | | `email` | TEXT | nullable | | `phone` | TEXT | nullable | | `ip` | INET | nullable | | `blocked_at` | TIMESTAMPTZ | | | `unblocked_at` | TIMESTAMPTZ | | Indexed on lowercase `email`, lowercase `username`, `phone`, `ip`. --- ### Junction tables | Table | Columns | |---|---| | `word_pictures` | `word_id` → `words`, `picture_id` → `pictures` | | `word_categories` | `word_id` → `words`, `category_id` → `categories` | | `object_words` | `object_id` → `objects`, `word_id` → `words` | | `object_pictures` | `object_id` → `objects`, `picture_id` → `pictures` | | `object_pairs` | `object_id` → `objects`, `pair_id` → `pairs` | | `statement_positive_words` | `statement_id` → `statements`, `word_id` → `words` | | `statement_negative_words` | `statement_id` → `statements`, `word_id` → `words` | All junction tables use `ON DELETE CASCADE` on both sides. --- ## API Endpoints ``` BASE = https://hyggecraftery.com/api/snakkimo TOKEN = ``` --- ### Auth (public — no token required) #### `POST /auth/register` ```bash curl -X POST "$BASE/auth/register" \ -H "Content-Type: application/json" \ -d '{"email": "user@example.com", "password": "sicher123"}' # → 201 { user: {id, email, role}, token } # → 403 if email is on blocklist # → 409 if email already registered ``` #### `POST /auth/login` ```bash curl -X POST "$BASE/auth/login" \ -H "Content-Type: application/json" \ -d '{"email": "user@example.com", "password": "sicher123"}' # → 200 { user: {id, email, role, native_lang}, token } # → 401 if wrong credentials # → 403 if account is inactive ``` JWT payload: `{ userId, email, role, native_lang }` — `native_lang` is the ISO 639-1 code of the user's native language (e.g. `"de"`). --- ### Pictures #### `GET /api/pictures` Query params: `status`, `objects_created` (`true`/`false`), `limit` (max 500, default 50), `offset` ```bash curl "$BASE/api/pictures?status=uploaded&objects_created=false" \ -H "Authorization: Bearer $TOKEN" ``` #### `GET /api/pictures/:id` #### `POST /api/pictures` Body fields: `generation_prompt`, `generation_timestamp`, `generation_duration_s`, `blurhash`, `design` #### `POST /api/pictures/:id/upload` `multipart/form-data`, field `file`, max 20 MB. Sets `picture_link`. #### `PATCH /api/pictures/:id` Writable: `status`, `blocked_reason`, `generation_prompt`, `generation_timestamp`, `generation_duration_s`, `published_timestamp`, `blocked_timestamp`, `blurhash`, `picture_link`, `design`, `objects_created`, `objects_created_at` Auto-behavior: - `status: "published"` → auto-sets `published_timestamp` - `status: "blocked"` → auto-sets `blocked_timestamp` - `objects_created: true` → auto-sets `objects_created_at` #### `DELETE /api/pictures/:id` Deletes DB row + Hetzner file. Returns `204`. #### Word links ``` POST /api/pictures/:id/words/:wordId DELETE /api/pictures/:id/words/:wordId GET /api/pictures/:id/words ``` --- ### Words #### `GET /api/words` Query params: `status`, `search` (ILIKE on `titel_de/en/sv`), `limit`, `offset` #### `GET /api/words/:id` Returns word + `picture_ids[]` + `category_ids[]` #### `POST /api/words` ```bash curl -X POST "$BASE/api/words" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"titel_de": "Hund", "titel_en": "Dog", "titel_sv": "Hund", "difficulty_level": 5}' ``` #### `PATCH /api/words/:id` Writable: `titel_de`, `titel_en`, `titel_sv`, `status`, `difficulty_level`, `requested_at`, `published_at`, `blocked_at` Auto: `status: "published"` → `published_at`, `status: "blocked"` → `blocked_at` #### `DELETE /api/words/:id` #### Links ``` POST /api/words/:id/pictures/:pictureId DELETE /api/words/:id/pictures/:pictureId POST /api/words/:id/categories/:categoryId DELETE /api/words/:id/categories/:categoryId ``` --- ### Categories #### `GET /api/categories` — `?status=&limit=&offset=` #### `GET /api/categories/:id` — includes `word_ids[]` #### `POST /api/categories` ```bash curl -X POST "$BASE/api/categories" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"titel_de": "Tiere", "titel_en": "Animals", "titel_sv": "Djur"}' ``` #### `PATCH /api/categories/:id` #### `DELETE /api/categories/:id` --- ### Questions #### `GET /api/questions` — `?status=&limit=&offset=` #### `GET /api/questions/:id` #### `POST /api/questions` ```bash curl -X POST "$BASE/api/questions" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"sentence_de": "Was ist das?", "status": "draft"}' ``` #### `PATCH /api/questions/:id` Writable: `status`, `sentence_de`, `sentence_en`, `sentence_sv`, `blocked_topic`, `published_at`, `blocked_at` #### `DELETE /api/questions/:id` --- ### Statements #### `GET /api/statements` — `?status=&limit=&offset=` #### `GET /api/statements/:id` Returns statement + `positive_word_ids[]` + `negative_word_ids[]` #### `POST /api/statements` ```bash curl -X POST "$BASE/api/statements" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "positive_sentence_de": "Das ist ein {{word-uuid}}.", "answer": true, "status": "draft" }' ``` Body fields: `positive_sentence_de/en/sv`, `negative_sentence_de/en/sv`, `answer` (boolean|null), `blocked_topic` #### `PATCH /api/statements/:id` Writable: `status`, `positive_sentence_de/en/sv`, `negative_sentence_de/en/sv`, `answer`, `blocked_topic`, `published_at`, `blocked_at` #### `DELETE /api/statements/:id` #### Word links ``` POST /api/statements/:id/positive-words/:wordId DELETE /api/statements/:id/positive-words/:wordId POST /api/statements/:id/negative-words/:wordId DELETE /api/statements/:id/negative-words/:wordId ``` --- ### Pairs #### `GET /api/pairs` — `?status=&answer_type=&limit=&offset=` #### `GET /api/pairs/:id` #### `POST /api/pairs` ```bash curl -X POST "$BASE/api/pairs" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "answer_type": "question", "question_id": "", "positive_statement_id": "", "negative_statement_id": "", "difficulty_level": 10 }' ``` `answer_type` is required. Valid values: `text`, `yes_no`, `question`, `word`. #### `PATCH /api/pairs/:id` Writable: `status`, `answer_type`, `difficulty_level`, `blocked_topic`, `question_id`, `positive_statement_id`, `negative_statement_id`, `published_at`, `blocked_at` #### `DELETE /api/pairs/:id` --- ### Objects #### `GET /api/objects` — `?status=&picture_id=&limit=&offset=` #### `GET /api/objects/:id` — includes `word_ids[]`, `picture_ids[]`, `pair_ids[]` #### `POST /api/objects` ```bash curl -X POST "$BASE/api/objects" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "selections": [{"points": [{"x": 0.1, "y": 0.2}, {"x": 0.5, "y": 0.2}, {"x": 0.3, "y": 0.7}]}], "status": "draft" }' ``` `selections` is a JSONB array of polygons with relative (0–1) coordinates. #### `PATCH /api/objects/:id` Writable: `status`, `selections`, `notes`, `blocked_topic`, `published_at`, `blocked_at` #### `DELETE /api/objects/:id` #### Links ``` GET /api/objects/:id/words POST /api/objects/:id/words/:wordId DELETE /api/objects/:id/words/:wordId POST /api/objects/:id/pictures/:pictureId DELETE /api/objects/:id/pictures/:pictureId GET /api/objects/:id/pairs POST /api/objects/:id/pairs/:pairId DELETE /api/objects/:id/pairs/:pairId ``` --- ### Blocklist #### `GET /api/blocklist` — `?is_blocked=true&limit=&offset=` #### `GET /api/blocklist/:id` #### `POST /api/blocklist` ```bash curl -X POST "$BASE/api/blocklist" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"email": "spam@example.com", "is_blocked": true}' ``` #### `PATCH /api/blocklist/:id` #### `DELETE /api/blocklist/:id` #### `POST /api/blocklist/check` Check if an email/username/phone/IP is blocked. ```bash curl -X POST "$BASE/api/blocklist/check" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"email": "test@example.com"}' # → { blocked: false } # → { blocked: true, entry: {...} } ``` --- ### Utilities #### `GET /api/tables` List all tables in the public schema. #### `GET /api/tables/:table` Read rows from any table (`?limit=100&offset=0`). #### `POST /api/query` Execute raw SQL. Destructive statements (`DROP`, `TRUNCATE`, `DELETE FROM`) require the header `X-Confirm-Destructive: yes`. ```bash curl -X POST "$BASE/api/query" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"sql": "SELECT count(*) FROM pictures"}' ``` --- ## Placeholder format Sentence fields in `questions` and `statements` store word/object references as `{{uuid}}`: ``` "Der {{abc-123}} bellt." ``` - `{{wordId}}` — links to `words.id` - `{{objectId}}` — links to `objects.id` The CMT resolves placeholders back to human-readable labels when displaying text for editing. --- ## Quick Start ```bash export TOKEN="dev_ccfd6fd149806a900311e253b0c3b5d1e107a68ab5619a5fde61f107ce9098ce" export BASE="https://hyggecraftery.com/api/snakkimo" # Create + upload a picture ID=$(curl -s -X POST "$BASE/api/pictures" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"generation_prompt": "A red apple on a table", "generation_duration_s": 3.1}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") curl -X POST "$BASE/api/pictures/$ID/upload" \ -H "Authorization: Bearer $TOKEN" \ -F "file=@apple.jpg" # Create a word WID=$(curl -s -X POST "$BASE/api/words" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"titel_de": "Apfel", "titel_en": "Apple", "titel_sv": "Äpple"}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") # Create a question pair QID=$(curl -s -X POST "$BASE/api/questions" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"sentence_de\": \"Was ist das?\", \"status\": \"draft\"}" \ | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") SID=$(curl -s -X POST "$BASE/api/statements" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"positive_sentence_de\": \"Das ist ein {{$WID}}.\", \"status\": \"draft\"}" \ | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") curl -s -X POST "$BASE/api/pairs" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"answer_type\": \"question\", \"question_id\": \"$QID\", \"positive_statement_id\": \"$SID\"}" ``` --- ## Deployment - **Platform:** Coolify (`snakkimo` project, `production` environment) - **App UUID:** `kmuk5zfthcfov4irjb07y6tx` - **Gitea repo:** `https://git.hyggecraftery.com/admin/snakkimo-API` - **Docker:** Node 20 Alpine, port 3000 - **Health check:** `GET /health` (Docker-native HEALTHCHECK) - **Migrations:** Run automatically on every server start (`src/db-migrate.js`) — fully idempotent