docs: complete README rewrite — current schema, auth, all endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 17:40:43 +02:00
parent b0a67df328
commit 52dce342f4

760
README.md
View File

@@ -4,19 +4,27 @@ REST API server for the snakkimo project. Connects to a PostgreSQL database and
- **Base URL:** `https://hyggecraftery.com/api/snakkimo`
- **Auth:** All `/api/*` routes require `Authorization: Bearer <token>`
- **Health:** `GET /health` — public, no token needed
- **Public routes:** `GET /health`, `POST /auth/register`, `POST /auth/login`
---
## Authentication
Pass the token in every request:
Two token types are accepted:
### 1. Static dev/admin tokens
Configured via the `API_TOKENS` environment variable (comma-separated). These bypass role checks and are used for server-to-server or CMT admin access.
```
Authorization: Bearer <token>
Authorization: Bearer dev_ccfd6fd1...
```
Tokens are configured via the `API_TOKENS` environment variable in Coolify (comma-separated for multiple tokens).
### 2. JWT tokens (end-users)
Issued by `POST /auth/register` and `POST /auth/login`. End-users with role `end-user` receive a **403** on all `/api/*` routes — JWT auth is currently used to gate app access only.
```
Authorization: Bearer eyJhbGci...
```
---
@@ -24,58 +32,21 @@ Tokens are configured via the `API_TOKENS` environment variable in Coolify (comm
| Variable | Description |
|---|---|
| `DB_HOST` | PostgreSQL internal hostname (Coolify service UUID) |
| `DB_HOST` | PostgreSQL hostname (Coolify internal) |
| `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 |
| `API_TOKENS` | Comma-separated static Bearer tokens |
| `JWT_SECRET` | Secret for signing JWTs (long random string) |
| `JWT_EXPIRES_IN` | JWT lifetime, e.g. `7d` |
| `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)
@@ -88,342 +59,520 @@ Deleting a picture via the API also deletes the file from the bucket.
---
## Database Relations
## Database Schema
**Host:** Coolify internal network · **Database:** `snakkimo` · **Schema:** `public`
All tables have `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` and `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` with an auto-update trigger unless noted otherwise.
### Relations overview
```
words ──────────── word_pictures ──────────── pictures
pictures ──── word_pictures ──────────────────── words ──── word_categories ──── categories
│ │
└──── object_pictures ──── objects ──── object_words
└──────────── word_categories ──────────── categories
object_pairs ──── pairs ──── questions
positive_statement_id ──┐
negative_statement_id ──┴── statements ──── statement_positive_words ──┐
└─── statement_negative_words ──┴── words
users ──── languages (native)
users_public ──── user_names
└── languages (native + target)
```
### Table: `words`
---
### `pictures`
| 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) |
| `id` | UUID PK | `gen_random_uuid()` | |
| `status` | VARCHAR(20) | `uploaded` | `uploaded` · `published` · `blocked` |
| `blocked_reason` | VARCHAR(20) | null | `regenerate` · `not_to_use` |
| `generation_prompt` | TEXT | null | |
| `generation_timestamp` | TIMESTAMPTZ | null | |
| `generation_duration_s` | NUMERIC(10,3) | null | |
| `published_timestamp` | TIMESTAMPTZ | null | |
| `blocked_timestamp` | TIMESTAMPTZ | null | |
| `blurhash` | TEXT | null | |
| `picture_link` | TEXT | null | Public Hetzner URL, set on upload |
| `design` | TEXT | null | Design category |
| `objects_created` | BOOLEAN | `false` | Set to true once all objects have been drawn |
| `objects_created_at` | TIMESTAMPTZ | null | Auto-set when `objects_created` → true |
**Status flow:** `requested → translated → generated → published` (blocked possible at any point)
**Junctions:** `word_pictures`, `object_pictures`
### Table: `word_pictures` (Junction)
| Column | Type |
|---|---|
| `word_id` | `UUID` FK → `words.id` CASCADE |
| `picture_id` | `UUID` FK → `pictures.id` CASCADE |
---
### Table: `categories`
### `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` · `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) |
| `id` | UUID PK | `gen_random_uuid()` | |
| `titel_de` | TEXT | null | German |
| `titel_en` | TEXT | null | English |
| `titel_sv` | TEXT | null | Swedish (ISO 639-1: `sv`) |
| `status` | VARCHAR(20) | `requested` | `requested` · `translated` · `generated` · `blocked` · `published` |
| `difficulty_level` | SMALLINT | null | 150 |
| `requested_at` | TIMESTAMPTZ | null | |
| `published_at` | TIMESTAMPTZ | null | Auto-set on status → `published` |
| `blocked_at` | TIMESTAMPTZ | null | Auto-set on status → `blocked` |
### Table: `objects`
**Junctions:** `word_pictures`, `word_categories`, `object_words`, `statement_positive_words`, `statement_negative_words`
---
### `categories`
| 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) |
| `id` | UUID PK | `gen_random_uuid()` | |
| `titel_de` | TEXT | null | |
| `titel_en` | TEXT | null | |
| `titel_sv` | TEXT | null | |
| `status` | VARCHAR(20) | `requested` | `requested` · `blocked` · `published` |
| `difficulty_level` | SMALLINT | null | 150 |
| `published_at` | TIMESTAMPTZ | null | |
| `blocked_at` | TIMESTAMPTZ | null | |
**Relations:** M2M mit `words`, `pictures`, `pairs` (pairs folgt später)
**Junctions:** `word_categories`
### Table: `pairs`
---
### `questions`
| 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) |
| `id` | UUID PK | `gen_random_uuid()` | |
| `status` | VARCHAR(20) | `draft` | `draft` · `blocked` · `published` |
| `sentence_de` | TEXT | null | German question sentence |
| `sentence_en` | TEXT | null | English |
| `sentence_sv` | TEXT | null | Swedish |
| `blocked_topic` | TEXT | null | |
| `published_at` | TIMESTAMPTZ | null | |
| `blocked_at` | TIMESTAMPTZ | null | |
### Table: `questions` (Platzhalter — Felder folgen)
| Column | Type |
|---|---|
| `id` | `UUID` PK |
| `created_at` / `updated_at` | `TIMESTAMPTZ` |
Sentence text may contain `{{uuid}}` placeholders referring to a `words.id` or `objects.id`.
### 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 |
### `statements`
### Table: `object_pictures` (Junction)
| Column | Type |
|---|---|
| `object_id` | `UUID` FK → `objects.id` CASCADE |
| `picture_id` | `UUID` FK → `pictures.id` CASCADE |
| Column | Type | Default | Description |
|---|---|---|---|
| `id` | UUID PK | `gen_random_uuid()` | |
| `status` | VARCHAR(20) | `draft` | `draft` · `blocked` · `published` |
| `positive_sentence_de` | TEXT | null | Positive form, German |
| `positive_sentence_en` | TEXT | null | |
| `positive_sentence_sv` | TEXT | null | |
| `negative_sentence_de` | TEXT | null | Negative form, German |
| `negative_sentence_en` | TEXT | null | |
| `negative_sentence_sv` | TEXT | null | |
| `answer` | BOOLEAN | null | For yes/no pairs: `true` = Ja, `false` = Nein, `null` = open |
| `blocked_topic` | TEXT | null | |
| `published_at` | TIMESTAMPTZ | null | |
| `blocked_at` | TIMESTAMPTZ | null | |
### Table: `object_pairs` (Junction)
| Column | Type |
|---|---|
| `object_id` | `UUID` FK → `objects.id` CASCADE |
| `pair_id` | `UUID` FK → `pairs.id` CASCADE |
Sentence text may contain `{{uuid}}` placeholders.
### Table: `word_categories` (Junction)
| Column | Type |
GET `/api/statements/:id` also returns `positive_word_ids[]` and `negative_word_ids[]`.
**Junctions:** `statement_positive_words`, `statement_negative_words`
---
### `pairs`
| Column | Type | Default | Description |
|---|---|---|---|
| `id` | UUID PK | `gen_random_uuid()` | |
| `status` | VARCHAR(20) | `draft` | `draft` · `blocked` · `published` |
| `answer_type` | TEXT | — | `text` · `yes_no` · `question` · `word` (required) |
| `difficulty_level` | SMALLINT | null | 150 |
| `blocked_topic` | TEXT | null | |
| `question_id` | UUID FK | null | → `questions.id` ON DELETE SET NULL |
| `positive_statement_id` | UUID FK | null | → `statements.id` ON DELETE SET NULL |
| `negative_statement_id` | UUID FK | null | → `statements.id` ON DELETE SET NULL |
| `published_at` | TIMESTAMPTZ | null | |
| `blocked_at` | TIMESTAMPTZ | null | |
**answer_type semantics:**
| Type | question | positive_statement | negative_statement |
|---|---|---|---|
| `text` | — | sentence (required) | — |
| `yes_no` | optional sentence | `answer` field (true/false/null) | — |
| `question` | sentence (required) | sentence (required) | sentence (optional) |
| `word` | optional sentence | linked via `statement_positive_words` | linked via `statement_negative_words` |
**Junctions:** `object_pairs`
---
### `objects`
| Column | Type | Default | Description |
|---|---|---|---|
| `id` | UUID PK | `gen_random_uuid()` | |
| `status` | VARCHAR(20) | `draft` | `draft` · `blocked` · `published` |
| `selections` | JSONB | null | `[{points: [{x: 0-1, y: 0-1}, …]}]` — relative coordinates |
| `notes` | TEXT | null | |
| `blocked_topic` | TEXT | null | |
| `published_at` | TIMESTAMPTZ | null | |
| `blocked_at` | TIMESTAMPTZ | null | |
**Junctions:** `object_words`, `object_pictures`, `object_pairs`
---
### `users` (CMT admin accounts)
| Column | Type | Default | Description |
|---|---|---|---|
| `id` | UUID PK | `gen_random_uuid()` | |
| `email` | TEXT UNIQUE | — | Lowercase-indexed |
| `password_hash` | TEXT | — | bcrypt hash |
| `role` | VARCHAR(20) | `end-user` | `end-user` · `admin` |
| `is_active` | BOOLEAN | `true` | False = locked out |
| `language_native_id` | UUID FK | null | → `languages.id` ON DELETE SET NULL |
---
### `languages`
| Column | Type | Default | Description |
|---|---|---|---|
| `id` | UUID PK | `gen_random_uuid()` | |
| `titel_de` | TEXT | null | |
| `titel_en` | TEXT | null | |
| `titel_sv` | TEXT | null | |
| `short_en` | VARCHAR(10) | null | ISO 639-1 code, e.g. `de`, `en`, `sv` |
| `status` | VARCHAR(20) | `draft` | `draft` · `blocked` · `published` |
| `published_at` | TIMESTAMPTZ | null | |
| `blocked_at` | TIMESTAMPTZ | null | |
| `blocked_topic` | TEXT | null | |
**Seeded:** German (`short_en = 'de'`) is automatically inserted on migration.
**Default:** All users without a native language are assigned German on migration.
---
### `user_names`
| Column | Type | Description |
|---|---|---|
| `id` | UUID PK | |
| `username_lowercase` | TEXT UNIQUE | Lowercase version for uniqueness check |
| `username` | TEXT | Display name (original casing) |
---
### `users_public` (app user profiles)
| Column | Type | Description |
|---|---|---|
| `id` | UUID PK | |
| `username_id` | UUID FK | → `user_names.id` ON DELETE SET NULL |
| `language_native_id` | UUID FK | → `languages.id` ON DELETE SET NULL |
| `language_target_id` | UUID FK | → `languages.id` ON DELETE SET NULL |
---
### `blocklist`
| Column | Type | Description |
|---|---|---|
| `id` | UUID PK | |
| `is_blocked` | BOOLEAN | `true` = active block |
| `username` | TEXT | nullable |
| `email` | TEXT | nullable |
| `phone` | TEXT | nullable |
| `ip` | INET | nullable |
| `blocked_at` | TIMESTAMPTZ | |
| `unblocked_at` | TIMESTAMPTZ | |
Indexed on lowercase `email`, lowercase `username`, `phone`, `ip`.
---
### Junction tables
| Table | Columns |
|---|---|
| `word_id` | `UUID` FK → `words.id` CASCADE |
| `category_id` | `UUID` FK`categories.id` CASCADE |
| `word_pictures` | `word_id``words`, `picture_id``pictures` |
| `word_categories` | `word_id` `words`, `category_id``categories` |
| `object_words` | `object_id``objects`, `word_id``words` |
| `object_pictures` | `object_id``objects`, `picture_id``pictures` |
| `object_pairs` | `object_id``objects`, `pair_id``pairs` |
| `statement_positive_words` | `statement_id``statements`, `word_id``words` |
| `statement_negative_words` | `statement_id``statements`, `word_id``words` |
All junction tables use `ON DELETE CASCADE` on both sides.
---
## API Endpoints
```
BASE = https://hyggecraftery.com/api/snakkimo
TOKEN = <your bearer token>
```
---
### Auth (public — no token required)
#### `POST /auth/register`
```bash
curl -X POST "$BASE/auth/register" \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "sicher123"}'
# → 201 { user: {id, email, role}, token }
# → 403 if email is on blocklist
# → 409 if email already registered
```
#### `POST /auth/login`
```bash
curl -X POST "$BASE/auth/login" \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "sicher123"}'
# → 200 { user: {id, email, role, native_lang}, token }
# → 401 if wrong credentials
# → 403 if account is inactive
```
JWT payload: `{ userId, email, role, native_lang }``native_lang` is the ISO 639-1 code of the user's native language (e.g. `"de"`).
---
### 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 |
Query params: `status`, `objects_created` (`true`/`false`), `limit` (max 500, default 50), `offset`
```bash
curl "$BASE/api/pictures" \
-H "Authorization: Bearer $TOKEN"
# With filter
curl "$BASE/api/pictures?status=published&limit=10" \
curl "$BASE/api/pictures?status=uploaded&objects_created=false" \
-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
}'
```
---
Body fields: `generation_prompt`, `generation_timestamp`, `generation_duration_s`, `blurhash`, `design`
#### `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`.
---
`multipart/form-data`, field `file`, max 20 MB. Sets `picture_link`.
#### `PATCH /api/pictures/:id`
Update one or more fields of a picture.
Writable: `status`, `blocked_reason`, `generation_prompt`, `generation_timestamp`, `generation_duration_s`, `published_timestamp`, `blocked_timestamp`, `blurhash`, `picture_link`, `design`, `objects_created`, `objects_created_at`
**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"}'
```
---
Auto-behavior:
- `status: "published"` → auto-sets `published_timestamp`
- `status: "blocked"` → auto-sets `blocked_timestamp`
- `objects_created: true` → auto-sets `objects_created_at`
#### `DELETE /api/pictures/:id`
Delete a picture entry from the database **and** remove the file from Hetzner Object Storage.
Deletes DB row + Hetzner file. Returns `204`.
Returns `204 No Content` on success.
```bash
curl -X DELETE "$BASE/api/pictures/<ID>" \
-H "Authorization: Bearer $TOKEN" \
-w "\nHTTP %{http_code}"
#### Word links
```
POST /api/pictures/:id/words/:wordId
DELETE /api/pictures/:id/words/:wordId
GET /api/pictures/:id/words
```
---
### 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
#### `GET /api/words`
Query params: `status`, `search` (ILIKE on `titel_de/en/sv`), `limit`, `offset`
#### `GET /api/words/:id`
Returns word + `picture_ids[]` + `category_ids[]`
#### `POST /api/words`
```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}'
-d '{"titel_de": "Hund", "titel_en": "Dog", "titel_sv": "Hund", "difficulty_level": 5}'
```
#### `PATCH /api/words/:id` — Felder aktualisieren (Status setzt Timestamp automatisch)
#### `PATCH /api/words/:id`
Writable: `titel_de`, `titel_en`, `titel_sv`, `status`, `difficulty_level`, `requested_at`, `published_at`, `blocked_at`
Auto: `status: "published"``published_at`, `status: "blocked"``blocked_at`
```bash
curl -X PATCH "$BASE/api/words/<ID>" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "published"}'
#### `DELETE /api/words/:id`
#### Links
```
POST /api/words/:id/pictures/:pictureId
DELETE /api/words/:id/pictures/:pictureId
POST /api/words/:id/categories/:categoryId
DELETE /api/words/:id/categories/:categoryId
```
#### `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
#### `GET /api/categories` — `?status=&limit=&offset=`
#### `GET /api/categories/:id` — includes `word_ids[]`
#### `POST /api/categories`
```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}'
-d '{"titel_de": "Tiere", "titel_en": "Animals", "titel_sv": "Djur"}'
```
#### `PATCH /api/categories/:id` — Felder aktualisieren (Status setzt Timestamp automatisch)
#### `DELETE /api/categories/:id` — Kategorie löschen (entfernt auch alle Verknüpfungen)
#### `PATCH /api/categories/:id`
#### `DELETE /api/categories/:id`
---
### 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
### Questions
#### `GET /api/questions` — `?status=&limit=&offset=`
#### `GET /api/questions/:id`
#### `POST /api/questions`
```bash
curl -X POST "$BASE/api/objects" \
curl -X POST "$BASE/api/questions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"notes": "Erste Notiz", "selections": {"key": "value"}}'
-d '{"sentence_de": "Was ist das?", "status": "draft"}'
```
#### `PATCH /api/questions/:id`
Writable: `status`, `sentence_de`, `sentence_en`, `sentence_sv`, `blocked_topic`, `published_at`, `blocked_at`
#### `PATCH /api/objects/:id` — Felder aktualisieren
#### `DELETE /api/questions/:id`
---
### Statements
#### `GET /api/statements` — `?status=&limit=&offset=`
#### `GET /api/statements/:id`
Returns statement + `positive_word_ids[]` + `negative_word_ids[]`
#### `POST /api/statements`
```bash
# Publizieren
curl -X PATCH "$BASE/api/objects/<ID>" \
curl -X POST "$BASE/api/statements" \
-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"}'
-d '{
"positive_sentence_de": "Das ist ein {{word-uuid}}.",
"answer": true,
"status": "draft"
}'
```
Body fields: `positive_sentence_de/en/sv`, `negative_sentence_de/en/sv`, `answer` (boolean|null), `blocked_topic`
#### `DELETE /api/objects/:id` — Object löschen (entfernt auch alle Verknüpfungen)
#### `PATCH /api/statements/:id`
Writable: `status`, `positive_sentence_de/en/sv`, `negative_sentence_de/en/sv`, `answer`, `blocked_topic`, `published_at`, `blocked_at`
#### `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
#### `DELETE /api/statements/:id`
#### Word links
```
POST /api/statements/:id/positive-words/:wordId
DELETE /api/statements/:id/positive-words/:wordId
POST /api/statements/:id/negative-words/:wordId
DELETE /api/statements/:id/negative-words/:wordId
```
---
### 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)
#### `GET /api/pairs` — `?status=&answer_type=&limit=&offset=`
#### `GET /api/pairs/:id`
#### `POST /api/pairs`
```bash
curl -X POST "$BASE/api/pairs" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"answer_type": "yes_no", "difficulty_level": 15}'
-d '{
"answer_type": "question",
"question_id": "<uuid>",
"positive_statement_id": "<uuid>",
"negative_statement_id": "<uuid>",
"difficulty_level": 10
}'
```
`answer_type` is required. Valid values: `text`, `yes_no`, `question`, `word`.
#### `PATCH /api/pairs/:id`
Writable: `status`, `answer_type`, `difficulty_level`, `blocked_topic`, `question_id`, `positive_statement_id`, `negative_statement_id`, `published_at`, `blocked_at`
#### `DELETE /api/pairs/:id`
---
### Objects
#### `GET /api/objects` — `?status=&picture_id=&limit=&offset=`
#### `GET /api/objects/:id` — includes `word_ids[]`, `picture_ids[]`, `pair_ids[]`
#### `POST /api/objects`
```bash
curl -X POST "$BASE/api/objects" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"selections": [{"points": [{"x": 0.1, "y": 0.2}, {"x": 0.5, "y": 0.2}, {"x": 0.3, "y": 0.7}]}],
"status": "draft"
}'
```
`selections` is a JSONB array of polygons with relative (01) coordinates.
#### `PATCH /api/objects/:id`
Writable: `status`, `selections`, `notes`, `blocked_topic`, `published_at`, `blocked_at`
#### `DELETE /api/objects/:id`
#### Links
```
GET /api/objects/:id/words
POST /api/objects/:id/words/:wordId
DELETE /api/objects/:id/words/:wordId
POST /api/objects/:id/pictures/:pictureId
DELETE /api/objects/:id/pictures/:pictureId
GET /api/objects/:id/pairs
POST /api/objects/:id/pairs/:pairId
DELETE /api/objects/:id/pairs/:pairId
```
#### `PATCH /api/pairs/:id` — Felder aktualisieren
#### `DELETE /api/pairs/:id` — Pair löschen
---
### Blocklist
#### `GET /api/blocklist` — `?is_blocked=true&limit=&offset=`
#### `GET /api/blocklist/:id`
#### `POST /api/blocklist`
```bash
curl -X POST "$BASE/api/blocklist" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"email": "spam@example.com", "is_blocked": true}'
```
#### `PATCH /api/blocklist/:id`
#### `DELETE /api/blocklist/:id`
#### `POST /api/blocklist/check`
Check if an email/username/phone/IP is blocked.
```bash
curl -X POST "$BASE/api/blocklist/check" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com"}'
# → { blocked: false }
# → { blocked: true, entry: {...} }
```
---
@@ -447,36 +596,62 @@ curl -X POST "$BASE/api/query" \
---
## Quick Start (Terminal)
## Placeholder format
Sentence fields in `questions` and `statements` store word/object references as `{{uuid}}`:
```
"Der {{abc-123}} bellt."
```
- `{{wordId}}` — links to `words.id`
- `{{objectId}}` — links to `objects.id`
The CMT resolves placeholders back to human-readable labels when displaying text for editing.
---
## Quick Start
```bash
# Set once
export TOKEN="dev_ccfd6fd149806a900311e253b0c3b5d1e107a68ab5619a5fde61f107ce9098ce"
export BASE="https://hyggecraftery.com/api/snakkimo"
# 1. Create entry
# Create + upload a picture
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}' \
-d '{"generation_prompt": "A red apple on a table", "generation_duration_s": 3.1}' \
| 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"
-F "file=@apple.jpg"
# 3. Publish
curl -X PATCH "$BASE/api/pictures/$ID" \
# Create a word
WID=$(curl -s -X POST "$BASE/api/words" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "published"}'
-d '{"titel_de": "Apfel", "titel_en": "Apple", "titel_sv": "Äpple"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
# 4. Delete (also removes from Hetzner)
curl -X DELETE "$BASE/api/pictures/$ID" \
-H "Authorization: Bearer $TOKEN"
# Create a question pair
QID=$(curl -s -X POST "$BASE/api/questions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"sentence_de\": \"Was ist das?\", \"status\": \"draft\"}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
SID=$(curl -s -X POST "$BASE/api/statements" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"positive_sentence_de\": \"Das ist ein {{$WID}}.\", \"status\": \"draft\"}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
curl -s -X POST "$BASE/api/pairs" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"answer_type\": \"question\", \"question_id\": \"$QID\", \"positive_statement_id\": \"$SID\"}"
```
---
@@ -484,7 +659,8 @@ curl -X DELETE "$BASE/api/pictures/$ID" \
## Deployment
- **Platform:** Coolify (`snakkimo` project, `production` environment)
- **App UUID:** `kmuk5zfthcfov4irjb07y6tx`
- **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)
- **Health check:** `GET /health` (Docker-native HEALTHCHECK)
- **Migrations:** Run automatically on every server start (`src/db-migrate.js`) — fully idempotent