Files
snakkimo-API/README.md
2026-05-25 17:40:43 +02:00

667 lines
21 KiB
Markdown
Raw Permalink 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>`
- **Public routes:** `GET /health`, `POST /auth/register`, `POST /auth/login`
---
## Authentication
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 dev_ccfd6fd1...
```
### 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...
```
---
## Environment Variables
| Variable | Description |
|---|---|
| `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 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 |
---
## 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 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
```
pictures ──── word_pictures ──────────────────── words ──── word_categories ──── categories
│ │
└──── object_pictures ──── objects ──── object_words
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)
```
---
### `pictures`
| Column | Type | Default | Description |
|---|---|---|---|
| `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 |
**Junctions:** `word_pictures`, `object_pictures`
---
### `words`
| Column | Type | Default | Description |
|---|---|---|---|
| `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` |
**Junctions:** `word_pictures`, `word_categories`, `object_words`, `statement_positive_words`, `statement_negative_words`
---
### `categories`
| Column | Type | Default | Description |
|---|---|---|---|
| `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 | |
**Junctions:** `word_categories`
---
### `questions`
| Column | Type | Default | Description |
|---|---|---|---|
| `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 | |
Sentence text may contain `{{uuid}}` placeholders referring to a `words.id` or `objects.id`.
---
### `statements`
| 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 | |
Sentence text may contain `{{uuid}}` placeholders.
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_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`
Query params: `status`, `objects_created` (`true`/`false`), `limit` (max 500, default 50), `offset`
```bash
curl "$BASE/api/pictures?status=uploaded&objects_created=false" \
-H "Authorization: Bearer $TOKEN"
```
#### `GET /api/pictures/:id`
#### `POST /api/pictures`
Body fields: `generation_prompt`, `generation_timestamp`, `generation_duration_s`, `blurhash`, `design`
#### `POST /api/pictures/:id/upload`
`multipart/form-data`, field `file`, max 20 MB. Sets `picture_link`.
#### `PATCH /api/pictures/:id`
Writable: `status`, `blocked_reason`, `generation_prompt`, `generation_timestamp`, `generation_duration_s`, `published_timestamp`, `blocked_timestamp`, `blurhash`, `picture_link`, `design`, `objects_created`, `objects_created_at`
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`
Deletes DB row + Hetzner file. Returns `204`.
#### Word links
```
POST /api/pictures/:id/words/:wordId
DELETE /api/pictures/:id/words/:wordId
GET /api/pictures/:id/words
```
---
### Words
#### `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_sv": "Hund", "difficulty_level": 5}'
```
#### `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`
#### `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
```
---
### Categories
#### `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_sv": "Djur"}'
```
#### `PATCH /api/categories/:id`
#### `DELETE /api/categories/:id`
---
### Questions
#### `GET /api/questions` — `?status=&limit=&offset=`
#### `GET /api/questions/:id`
#### `POST /api/questions`
```bash
curl -X POST "$BASE/api/questions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-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`
#### `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
curl -X POST "$BASE/api/statements" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-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`
#### `PATCH /api/statements/:id`
Writable: `status`, `positive_sentence_de/en/sv`, `negative_sentence_de/en/sv`, `answer`, `blocked_topic`, `published_at`, `blocked_at`
#### `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` — `?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": "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
```
---
### 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: {...} }
```
---
### 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"}'
```
---
## 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
export TOKEN="dev_ccfd6fd149806a900311e253b0c3b5d1e107a68ab5619a5fde61f107ce9098ce"
export BASE="https://hyggecraftery.com/api/snakkimo"
# Create + upload a picture
ID=$(curl -s -X POST "$BASE/api/pictures" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-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'])")
curl -X POST "$BASE/api/pictures/$ID/upload" \
-H "Authorization: Bearer $TOKEN" \
-F "file=@apple.jpg"
# Create a word
WID=$(curl -s -X POST "$BASE/api/words" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"titel_de": "Apfel", "titel_en": "Apple", "titel_sv": "Äpple"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
# 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\"}"
```
---
## 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:** `GET /health` (Docker-native HEALTHCHECK)
- **Migrations:** Run automatically on every server start (`src/db-migrate.js`) — fully idempotent