# 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 ` - **Health:** `GET /health` — public, no token needed --- ## Authentication Pass the token in every request: ``` Authorization: Bearer ``` Tokens are configured via the `API_TOKENS` environment variable in Coolify (comma-separated for multiple tokens). --- ## Environment Variables | Variable | Description | |---|---| | `DB_HOST` | PostgreSQL internal hostname (Coolify service UUID) | | `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 Bearer tokens | | `S3_ACCESS_KEY` | Hetzner Object Storage access key | | `S3_SECRET_KEY` | Hetzner Object Storage secret key | --- ## Database **Host:** Coolify internal network (same project as the API) **Database:** `snakkimo` **Schema:** `public` ### Table: `pictures` Stores AI-generated pictures with metadata. | Column | Type | Default | Description | |---|---|---|---| | `id` | `UUID` | `gen_random_uuid()` | Primary key, auto-generated | | `status` | `VARCHAR(20)` | `uploaded` | `uploaded` · `published` · `blocked` | | `blocked_reason` | `VARCHAR(20)` | `null` | `regenerate` · `not_to_use` | | `generation_prompt` | `TEXT` | `null` | Prompt used to generate the image | | `generation_timestamp` | `TIMESTAMPTZ` | `null` | When the generation was started | | `generation_duration_s` | `NUMERIC(10,3)` | `null` | How long generation took in seconds | | `published_timestamp` | `TIMESTAMPTZ` | `null` | Auto-set when status → `published` | | `blocked_timestamp` | `TIMESTAMPTZ` | `null` | Auto-set when status → `blocked` | | `blurhash` | `TEXT` | `null` | Blurhash string for image placeholder | | `picture_link` | `TEXT` | `null` | Public Hetzner URL — set automatically on upload | | `design` | `TEXT` | `null` | Design category (values TBD) | | `created_at` | `TIMESTAMPTZ` | `NOW()` | Auto-set on insert | | `updated_at` | `TIMESTAMPTZ` | `NOW()` | Auto-updated on every change (trigger) | **Status flow:** ``` uploaded → published uploaded → blocked published → blocked ``` **Planned relations:** - `design` → will become a FK to a `designs` table - `words` → M2M via `word_pictures` junction table --- ## 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 Relations ``` words ──────────── word_pictures ──────────── pictures │ └──────────── word_categories ──────────── categories ``` ### Table: `words` | Column | Type | Default | Description | |---|---|---|---| | `id` | `UUID` | `gen_random_uuid()` | Primary key | | `titel_de` | `TEXT` | `null` | German title | | `titel_en` | `TEXT` | `null` | English title | | `titel_se` | `TEXT` | `null` | Swedish title | | `status` | `VARCHAR(20)` | `requested` | `requested` · `translated` · `generated` · `blocked` · `published` | | `difficulty_level` | `SMALLINT` | `null` | 1–50 | | `requested_at` | `TIMESTAMPTZ` | `NOW()` on insert | Auto-set on create | | `published_at` | `TIMESTAMPTZ` | `null` | Auto-set when status → `published` | | `blocked_at` | `TIMESTAMPTZ` | `null` | Auto-set when status → `blocked` | | `created_at` | `TIMESTAMPTZ` | `NOW()` | Auto-set on insert | | `updated_at` | `TIMESTAMPTZ` | `NOW()` | Auto-updated on every change (trigger) | **Status flow:** `requested → translated → generated → published` (blocked possible at any point) ### Table: `word_pictures` (Junction) | Column | Type | |---|---| | `word_id` | `UUID` FK → `words.id` CASCADE | | `picture_id` | `UUID` FK → `pictures.id` CASCADE | ### Table: `categories` | Column | Type | Default | Description | |---|---|---|---| | `id` | `UUID` | `gen_random_uuid()` | Primary key | | `titel_de` | `TEXT` | `null` | German title | | `titel_en` | `TEXT` | `null` | English title | | `titel_se` | `TEXT` | `null` | Swedish title | | `status` | `VARCHAR(20)` | `requested` | `requested` · `blocked` · `published` | | `difficulty_level` | `SMALLINT` | `null` | 1–50 | | `requested_at` | `TIMESTAMPTZ` | `NOW()` on insert | Auto-set on create | | `published_at` | `TIMESTAMPTZ` | `null` | Auto-set when status → `published` | | `blocked_at` | `TIMESTAMPTZ` | `null` | Auto-set when status → `blocked` | | `created_at` | `TIMESTAMPTZ` | `NOW()` | Auto-set on insert | | `updated_at` | `TIMESTAMPTZ` | `NOW()` | Auto-updated on every change (trigger) | ### Table: `objects` | Column | Type | Default | Description | |---|---|---|---| | `id` | `UUID` | `gen_random_uuid()` | Primary key | | `status` | `VARCHAR(20)` | `draft` | `draft` · `blocked` · `published` | | `selections` | `JSONB` | `null` | Frei strukturierter JSON-Block | | `notes` | `TEXT` | `null` | Notizen | | `blocked_topic` | `TEXT` | `null` | Grund/Thema der Blockierung | | `published_at` | `TIMESTAMPTZ` | `null` | Auto-set when status → `published` | | `blocked_at` | `TIMESTAMPTZ` | `null` | Auto-set when status → `blocked` | | `created_at` | `TIMESTAMPTZ` | `NOW()` | Auto-set on insert | | `updated_at` | `TIMESTAMPTZ` | `NOW()` | Auto-updated on every change (trigger) | **Relations:** M2M mit `words`, `pictures`, `pairs` (pairs folgt später) ### Table: `pairs` (Platzhalter — Felder folgen) | Column | Type | |---|---| | `id` | `UUID` PK | | `created_at` | `TIMESTAMPTZ` | | `updated_at` | `TIMESTAMPTZ` | ### Table: `object_words` (Junction) | Column | Type | |---|---| | `object_id` | `UUID` FK → `objects.id` CASCADE | | `word_id` | `UUID` FK → `words.id` CASCADE | ### Table: `object_pictures` (Junction) | Column | Type | |---|---| | `object_id` | `UUID` FK → `objects.id` CASCADE | | `picture_id` | `UUID` FK → `pictures.id` CASCADE | ### Table: `object_pairs` (Junction) | Column | Type | |---|---| | `object_id` | `UUID` FK → `objects.id` CASCADE | | `pair_id` | `UUID` FK → `pairs.id` CASCADE | ### Table: `word_categories` (Junction) | Column | Type | |---|---| | `word_id` | `UUID` FK → `words.id` CASCADE | | `category_id` | `UUID` FK → `categories.id` CASCADE | --- ## API Endpoints ### Pictures #### `GET /api/pictures` List all pictures, newest first. **Query params:** | Param | Default | Description | |---|---|---| | `status` | — | Filter by status (`uploaded`, `published`, `blocked`) | | `limit` | `50` | Max rows (max 500) | | `offset` | `0` | Pagination offset | ```bash curl "$BASE/api/pictures" \ -H "Authorization: Bearer $TOKEN" # With filter curl "$BASE/api/pictures?status=published&limit=10" \ -H "Authorization: Bearer $TOKEN" ``` --- #### `GET /api/pictures/:id` Get a single picture by UUID. ```bash curl "$BASE/api/pictures/88602a2b-e608-429a-9e69-78a772128cb8" \ -H "Authorization: Bearer $TOKEN" ``` --- #### `POST /api/pictures` Create a new picture entry. Returns the new row with status `uploaded`. **Body (JSON):** | Field | Type | Description | |---|---|---| | `generation_prompt` | string | Prompt text | | `generation_timestamp` | ISO 8601 | When generation started | | `generation_duration_s` | number | Duration in seconds | | `blurhash` | string | Blurhash placeholder | | `design` | string | Design category | ```bash curl -X POST "$BASE/api/pictures" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "generation_prompt": "A sunset over mountains", "generation_duration_s": 4.2 }' ``` --- #### `POST /api/pictures/:id/upload` Upload an image file to Hetzner and store the public URL in `picture_link`. Replaces any previously uploaded file for this entry. **Body:** `multipart/form-data`, field name `file`, max 20 MB. ```bash curl -X POST "$BASE/api/pictures//upload" \ -H "Authorization: Bearer $TOKEN" \ -F "file=@/path/to/image.jpg" ``` **Response:** Updated picture row including `picture_link`. --- #### `PATCH /api/pictures/:id` Update one or more fields of a picture. **Writable fields:** `status`, `blocked_reason`, `generation_prompt`, `generation_timestamp`, `generation_duration_s`, `published_timestamp`, `blocked_timestamp`, `blurhash`, `picture_link`, `design` **Auto-behavior:** - Setting `status: "published"` auto-sets `published_timestamp` to now (if not provided) - Setting `status: "blocked"` auto-sets `blocked_timestamp` to now (if not provided) ```bash # Publish a picture curl -X PATCH "$BASE/api/pictures/" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"status": "published"}' # Block with reason curl -X PATCH "$BASE/api/pictures/" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"status": "blocked", "blocked_reason": "not_to_use"}' ``` --- #### `DELETE /api/pictures/:id` Delete a picture entry from the database **and** remove the file from Hetzner Object Storage. Returns `204 No Content` on success. ```bash curl -X DELETE "$BASE/api/pictures/" \ -H "Authorization: Bearer $TOKEN" \ -w "\nHTTP %{http_code}" ``` --- ### Words #### `GET /api/words` — Liste (`?status=requested&limit=50&offset=0`) #### `GET /api/words/:id` — Einzelnes Word inkl. `picture_ids` und `category_ids` #### `POST /api/words` — Neues Word anlegen ```bash curl -X POST "$BASE/api/words" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"titel_de": "Hund", "titel_en": "Dog", "titel_se": "Hund", "difficulty_level": 5}' ``` #### `PATCH /api/words/:id` — Felder aktualisieren (Status setzt Timestamp automatisch) ```bash curl -X PATCH "$BASE/api/words/" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"status": "published"}' ``` #### `DELETE /api/words/:id` — Word löschen (entfernt auch alle Verknüpfungen) #### `POST /api/words/:id/pictures/:pictureId` — Bild verknüpfen #### `DELETE /api/words/:id/pictures/:pictureId` — Bild-Verknüpfung lösen #### `POST /api/words/:id/categories/:categoryId` — Kategorie verknüpfen #### `DELETE /api/words/:id/categories/:categoryId` — Kategorie-Verknüpfung lösen --- ### Categories #### `GET /api/categories` — Liste (`?status=requested&limit=50&offset=0`) #### `GET /api/categories/:id` — Einzelne Kategorie inkl. `word_ids` #### `POST /api/categories` — Neue Kategorie anlegen ```bash curl -X POST "$BASE/api/categories" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"titel_de": "Tiere", "titel_en": "Animals", "titel_se": "Djur", "difficulty_level": 10}' ``` #### `PATCH /api/categories/:id` — Felder aktualisieren (Status setzt Timestamp automatisch) #### `DELETE /api/categories/:id` — Kategorie löschen (entfernt auch alle Verknüpfungen) --- ### Objects #### `GET /api/objects` — Liste (`?status=draft&limit=50&offset=0`) #### `GET /api/objects/:id` — Einzelnes Object inkl. `word_ids`, `picture_ids`, `pair_ids` #### `POST /api/objects` — Neues Object anlegen ```bash curl -X POST "$BASE/api/objects" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"notes": "Erste Notiz", "selections": {"key": "value"}}' ``` #### `PATCH /api/objects/:id` — Felder aktualisieren ```bash # Publizieren curl -X PATCH "$BASE/api/objects/" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"status": "published"}' # Blockieren mit Thema curl -X PATCH "$BASE/api/objects/" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"status": "blocked", "blocked_topic": "Inhalt ungeeignet"}' ``` #### `DELETE /api/objects/:id` — Object löschen (entfernt auch alle Verknüpfungen) #### `POST /api/objects/:id/words/:wordId` — Word verknüpfen #### `DELETE /api/objects/:id/words/:wordId` — Word-Verknüpfung lösen #### `POST /api/objects/:id/pictures/:pictureId` — Bild verknüpfen #### `DELETE /api/objects/:id/pictures/:pictureId` — Bild-Verknüpfung lösen #### `POST /api/objects/:id/pairs/:pairId` — Pair verknüpfen #### `DELETE /api/objects/:id/pairs/:pairId` — Pair-Verknüpfung lösen --- ### 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"}' ``` --- ## Quick Start (Terminal) ```bash # Set once export TOKEN="dev_ccfd6fd149806a900311e253b0c3b5d1e107a68ab5619a5fde61f107ce9098ce" export BASE="https://hyggecraftery.com/api/snakkimo" # 1. Create entry ID=$(curl -s -X POST "$BASE/api/pictures" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"generation_prompt": "test", "generation_duration_s": 1.5}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") echo "Created: $ID" # 2. Upload image curl -X POST "$BASE/api/pictures/$ID/upload" \ -H "Authorization: Bearer $TOKEN" \ -F "file=@/path/to/image.jpg" # 3. Publish curl -X PATCH "$BASE/api/pictures/$ID" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"status": "published"}' # 4. Delete (also removes from Hetzner) curl -X DELETE "$BASE/api/pictures/$ID" \ -H "Authorization: Bearer $TOKEN" ``` --- ## Deployment - **Platform:** Coolify (`snakkimo` project, `production` environment) - **Gitea repo:** `https://git.hyggecraftery.com/admin/snakkimo-API` - **Docker:** Node 20 Alpine, port 3000 - **Health check:** Docker-native `HEALTHCHECK` on `GET /health` - **Migrations:** Run automatically on every server start (idempotent)