Files
snakkimo-API/README.md
admin dac991c861 feat: objects table with M2M words/pictures/pairs
- objects: status enum, JSONB selections, notes, blocked_topic, auto-timestamps
- pairs placeholder table for future use
- Junction tables: object_words, object_pictures, object_pairs
- Full CRUD + link/unlink endpoints for all three relations
- README updated

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

451 lines
14 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` (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/<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
---
### 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)