- pictures: add objects_created (bool) + objects_created_at (auto timestamp) GET /pictures supports ?objects_created=true/false filter PATCH /pictures/:id allows setting objects_created - db-migrate: seed German language, link to all existing users - auth/login: include native_lang (from languages table) in response + JWT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 requireAuthorization: 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 adesignstablewords→ M2M viaword_picturesjunction 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 |
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
| 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 |
1–50 |
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-setspublished_timestampto now (if not provided) - Setting
status: "blocked"auto-setsblocked_timestampto 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 (
snakkimoproject,productionenvironment) - Gitea repo:
https://git.hyggecraftery.com/admin/snakkimo-API - Docker: Node 20 Alpine, port 3000
- Health check: Docker-native
HEALTHCHECKonGET /health - Migrations: Run automatically on every server start (idempotent)