Files
snakkimo-API/README.md
admin 30d180a3de feat: pairs table with questions/statements placeholders
- pairs: status, answer_type enum (yes_no/text/word), difficulty_level,
  FK to questions + 2x statements (positive/negative), auto-timestamps
- questions + statements placeholder tables for future use
- Safe ALTER TABLE migration for existing pairs placeholder
- /api/pairs CRUD route, answer_type required on create

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 10:00:35 +02:00

491 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
```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/<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)
```bash
# 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.
```bash
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
```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/<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
```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/<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)
```bash
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`.
```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)