- Fills out placeholder categories table with all fields - Trilingual titles, status enum, difficulty level, auto-timestamps - ALTER TABLE IF NOT EXISTS for safe migration on existing table - /api/categories CRUD route, word_ids included in responses Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
370 lines
11 KiB
Markdown
370 lines
11 KiB
Markdown
# 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` | 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: `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)
|
||
|
||
---
|
||
|
||
### 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)
|