Files
snakkimo-API/README.md
admin 8751d7ceae feat: words table, M2M with pictures and categories
- words table with trilingual titles, status enum, difficulty level, timestamps
- word_pictures junction table (M2M words <-> pictures)
- categories placeholder table
- word_categories junction table (M2M words <-> categories)
- Auto-timestamps on status change (requested/published/blocked)
- Full CRUD + link/unlink endpoints for pictures and categories
- README updated with schema and endpoints

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

343 lines
10 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` (Platzhalter — Felder folgen)
| Column | Type |
|---|---|
| `id` | `UUID` PK |
| `created_at` | `TIMESTAMPTZ` |
| `updated_at` | `TIMESTAMPTZ` |
### 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
---
### 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)