admin 7c8d5bfaaf feat: pairs answer_type TEXT[], statements.answer bool
- pairs: answer_type changed from VARCHAR(20) to TEXT[] (multi-value)
  POST/PATCH now accept arrays like ['text','yes_no','word']
- statements: add answer BOOLEAN (nullable) for yes/no correct answer
- migration: ALTER TABLE pairs + ADD COLUMN statements.answer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:48:29 +02:00

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 <token>
  • Health: GET /health — public, no token needed

Authentication

Pass the token in every request:

Authorization: Bearer <token>

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/<picture_id>/<uuid>.<ext>. 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 150
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 150
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

Column Type Default Description
id UUID gen_random_uuid() Primary key
status VARCHAR(20) draft draft · blocked · published
answer_type VARCHAR(20) yes_no · text · word (required)
difficulty_level SMALLINT null 150
blocked_topic TEXT null Grund/Thema der Blockierung
question_id UUID FK null questions.id (SET NULL on delete)
positive_statement_id UUID FK null statements.id (SET NULL on delete)
negative_statement_id UUID FK null statements.id (SET NULL on delete)
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: questions (Platzhalter — Felder folgen)

Column Type
id UUID PK
created_at / updated_at TIMESTAMPTZ

Table: statements (Platzhalter — Felder folgen)

Column Type
id UUID PK
created_at / 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
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.

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
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.

curl -X POST "$BASE/api/pictures/<ID>/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)
# Publish a picture
curl -X PATCH "$BASE/api/pictures/<ID>" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": "published"}'

# Block with reason
curl -X PATCH "$BASE/api/pictures/<ID>" \
  -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.

curl -X DELETE "$BASE/api/pictures/<ID>" \
  -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

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)

curl -X PATCH "$BASE/api/words/<ID>" \
  -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

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

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

# Publizieren
curl -X PATCH "$BASE/api/objects/<ID>" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": "published"}'

# Blockieren mit Thema
curl -X PATCH "$BASE/api/objects/<ID>" \
  -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


Pairs

GET /api/pairs — Liste (?status=draft&answer_type=yes_no&limit=50)

GET /api/pairs/:id — Einzelnes Pair

POST /api/pairs — Neues Pair anlegen (answer_type ist Pflichtfeld)

curl -X POST "$BASE/api/pairs" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"answer_type": "yes_no", "difficulty_level": 15}'

PATCH /api/pairs/:id — Felder aktualisieren

DELETE /api/pairs/:id — Pair löschen


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.

curl -X POST "$BASE/api/query" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"sql": "SELECT count(*) FROM pictures"}'

Quick Start (Terminal)

# 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)
Description
API server for snakkimo PostgreSQL
Readme 873 KiB
Languages
JavaScript 99.9%