docs: complete README rewrite — current schema, auth, all endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
760
README.md
760
README.md
@@ -4,19 +4,27 @@ REST API server for the snakkimo project. Connects to a PostgreSQL database and
|
|||||||
|
|
||||||
- **Base URL:** `https://hyggecraftery.com/api/snakkimo`
|
- **Base URL:** `https://hyggecraftery.com/api/snakkimo`
|
||||||
- **Auth:** All `/api/*` routes require `Authorization: Bearer <token>`
|
- **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
|
## 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 |
|
| Variable | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `DB_HOST` | PostgreSQL internal hostname (Coolify service UUID) |
|
| `DB_HOST` | PostgreSQL hostname (Coolify internal) |
|
||||||
| `DB_PORT` | PostgreSQL port (default: `5432`) |
|
| `DB_PORT` | PostgreSQL port (default: `5432`) |
|
||||||
| `DB_NAME` | Database name |
|
| `DB_NAME` | Database name |
|
||||||
| `DB_USER` | Database user |
|
| `DB_USER` | Database user |
|
||||||
| `DB_PASSWORD` | Database password |
|
| `DB_PASSWORD` | Database password |
|
||||||
| `DB_SSL` | `true` or `false` |
|
| `DB_SSL` | `true` or `false` |
|
||||||
| `PORT` | API server port (default: `3000`) |
|
| `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_ACCESS_KEY` | Hetzner Object Storage access key |
|
||||||
| `S3_SECRET_KEY` | Hetzner Object Storage secret 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
|
## Storage
|
||||||
|
|
||||||
**Provider:** Hetzner Object Storage (S3-compatible)
|
**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 |
|
| Column | Type | Default | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `id` | `UUID` | `gen_random_uuid()` | Primary key |
|
| `id` | UUID PK | `gen_random_uuid()` | |
|
||||||
| `titel_de` | `TEXT` | `null` | German title |
|
| `status` | VARCHAR(20) | `uploaded` | `uploaded` · `published` · `blocked` |
|
||||||
| `titel_en` | `TEXT` | `null` | English title |
|
| `blocked_reason` | VARCHAR(20) | null | `regenerate` · `not_to_use` |
|
||||||
| `titel_se` | `TEXT` | `null` | Swedish title |
|
| `generation_prompt` | TEXT | null | |
|
||||||
| `status` | `VARCHAR(20)` | `requested` | `requested` · `translated` · `generated` · `blocked` · `published` |
|
| `generation_timestamp` | TIMESTAMPTZ | null | |
|
||||||
| `difficulty_level` | `SMALLINT` | `null` | 1–50 |
|
| `generation_duration_s` | NUMERIC(10,3) | null | |
|
||||||
| `requested_at` | `TIMESTAMPTZ` | `NOW()` on insert | Auto-set on create |
|
| `published_timestamp` | TIMESTAMPTZ | null | |
|
||||||
| `published_at` | `TIMESTAMPTZ` | `null` | Auto-set when status → `published` |
|
| `blocked_timestamp` | TIMESTAMPTZ | null | |
|
||||||
| `blocked_at` | `TIMESTAMPTZ` | `null` | Auto-set when status → `blocked` |
|
| `blurhash` | TEXT | null | |
|
||||||
| `created_at` | `TIMESTAMPTZ` | `NOW()` | Auto-set on insert |
|
| `picture_link` | TEXT | null | Public Hetzner URL, set on upload |
|
||||||
| `updated_at` | `TIMESTAMPTZ` | `NOW()` | Auto-updated on every change (trigger) |
|
| `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 |
|
| Column | Type | Default | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `id` | `UUID` | `gen_random_uuid()` | Primary key |
|
| `id` | UUID PK | `gen_random_uuid()` | |
|
||||||
| `titel_de` | `TEXT` | `null` | German title |
|
| `titel_de` | TEXT | null | German |
|
||||||
| `titel_en` | `TEXT` | `null` | English title |
|
| `titel_en` | TEXT | null | English |
|
||||||
| `titel_se` | `TEXT` | `null` | Swedish title |
|
| `titel_sv` | TEXT | null | Swedish (ISO 639-1: `sv`) |
|
||||||
| `status` | `VARCHAR(20)` | `requested` | `requested` · `blocked` · `published` |
|
| `status` | VARCHAR(20) | `requested` | `requested` · `translated` · `generated` · `blocked` · `published` |
|
||||||
| `difficulty_level` | `SMALLINT` | `null` | 1–50 |
|
| `difficulty_level` | SMALLINT | null | 1–50 |
|
||||||
| `requested_at` | `TIMESTAMPTZ` | `NOW()` on insert | Auto-set on create |
|
| `requested_at` | TIMESTAMPTZ | null | |
|
||||||
| `published_at` | `TIMESTAMPTZ` | `null` | Auto-set when status → `published` |
|
| `published_at` | TIMESTAMPTZ | null | Auto-set on status → `published` |
|
||||||
| `blocked_at` | `TIMESTAMPTZ` | `null` | Auto-set when status → `blocked` |
|
| `blocked_at` | TIMESTAMPTZ | null | Auto-set on status → `blocked` |
|
||||||
| `created_at` | `TIMESTAMPTZ` | `NOW()` | Auto-set on insert |
|
|
||||||
| `updated_at` | `TIMESTAMPTZ` | `NOW()` | Auto-updated on every change (trigger) |
|
|
||||||
|
|
||||||
### Table: `objects`
|
**Junctions:** `word_pictures`, `word_categories`, `object_words`, `statement_positive_words`, `statement_negative_words`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `categories`
|
||||||
|
|
||||||
| Column | Type | Default | Description |
|
| Column | Type | Default | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `id` | `UUID` | `gen_random_uuid()` | Primary key |
|
| `id` | UUID PK | `gen_random_uuid()` | |
|
||||||
| `status` | `VARCHAR(20)` | `draft` | `draft` · `blocked` · `published` |
|
| `titel_de` | TEXT | null | |
|
||||||
| `selections` | `JSONB` | `null` | Frei strukturierter JSON-Block |
|
| `titel_en` | TEXT | null | |
|
||||||
| `notes` | `TEXT` | `null` | Notizen |
|
| `titel_sv` | TEXT | null | |
|
||||||
| `blocked_topic` | `TEXT` | `null` | Grund/Thema der Blockierung |
|
| `status` | VARCHAR(20) | `requested` | `requested` · `blocked` · `published` |
|
||||||
| `published_at` | `TIMESTAMPTZ` | `null` | Auto-set when status → `published` |
|
| `difficulty_level` | SMALLINT | null | 1–50 |
|
||||||
| `blocked_at` | `TIMESTAMPTZ` | `null` | Auto-set when status → `blocked` |
|
| `published_at` | TIMESTAMPTZ | null | |
|
||||||
| `created_at` | `TIMESTAMPTZ` | `NOW()` | Auto-set on insert |
|
| `blocked_at` | TIMESTAMPTZ | null | |
|
||||||
| `updated_at` | `TIMESTAMPTZ` | `NOW()` | Auto-updated on every change (trigger) |
|
|
||||||
|
|
||||||
**Relations:** M2M mit `words`, `pictures`, `pairs` (pairs folgt später)
|
**Junctions:** `word_categories`
|
||||||
|
|
||||||
### Table: `pairs`
|
---
|
||||||
|
|
||||||
|
### `questions`
|
||||||
|
|
||||||
| Column | Type | Default | Description |
|
| Column | Type | Default | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `id` | `UUID` | `gen_random_uuid()` | Primary key |
|
| `id` | UUID PK | `gen_random_uuid()` | |
|
||||||
| `status` | `VARCHAR(20)` | `draft` | `draft` · `blocked` · `published` |
|
| `status` | VARCHAR(20) | `draft` | `draft` · `blocked` · `published` |
|
||||||
| `answer_type` | `VARCHAR(20)` | — | `yes_no` · `text` · `word` (required) |
|
| `sentence_de` | TEXT | null | German question sentence |
|
||||||
| `difficulty_level` | `SMALLINT` | `null` | 1–50 |
|
| `sentence_en` | TEXT | null | English |
|
||||||
| `blocked_topic` | `TEXT` | `null` | Grund/Thema der Blockierung |
|
| `sentence_sv` | TEXT | null | Swedish |
|
||||||
| `question_id` | `UUID` FK | `null` | → `questions.id` (SET NULL on delete) |
|
| `blocked_topic` | TEXT | null | |
|
||||||
| `positive_statement_id` | `UUID` FK | `null` | → `statements.id` (SET NULL on delete) |
|
| `published_at` | TIMESTAMPTZ | null | |
|
||||||
| `negative_statement_id` | `UUID` FK | `null` | → `statements.id` (SET NULL on delete) |
|
| `blocked_at` | TIMESTAMPTZ | null | |
|
||||||
| `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: `questions` (Platzhalter — Felder folgen)
|
Sentence text may contain `{{uuid}}` placeholders referring to a `words.id` or `objects.id`.
|
||||||
| Column | Type |
|
|
||||||
|---|---|
|
|
||||||
| `id` | `UUID` PK |
|
|
||||||
| `created_at` / `updated_at` | `TIMESTAMPTZ` |
|
|
||||||
|
|
||||||
### Table: `statements` (Platzhalter — Felder folgen)
|
---
|
||||||
| Column | Type |
|
|
||||||
|---|---|
|
|
||||||
| `id` | `UUID` PK |
|
|
||||||
| `created_at` / `updated_at` | `TIMESTAMPTZ` |
|
|
||||||
|
|
||||||
### Table: `object_words` (Junction)
|
### `statements`
|
||||||
| Column | Type |
|
|
||||||
|---|---|
|
|
||||||
| `object_id` | `UUID` FK → `objects.id` CASCADE |
|
|
||||||
| `word_id` | `UUID` FK → `words.id` CASCADE |
|
|
||||||
|
|
||||||
### Table: `object_pictures` (Junction)
|
| Column | Type | Default | Description |
|
||||||
| Column | Type |
|
|---|---|---|---|
|
||||||
|---|---|
|
| `id` | UUID PK | `gen_random_uuid()` | |
|
||||||
| `object_id` | `UUID` FK → `objects.id` CASCADE |
|
| `status` | VARCHAR(20) | `draft` | `draft` · `blocked` · `published` |
|
||||||
| `picture_id` | `UUID` FK → `pictures.id` CASCADE |
|
| `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)
|
Sentence text may contain `{{uuid}}` placeholders.
|
||||||
| Column | Type |
|
|
||||||
|---|---|
|
|
||||||
| `object_id` | `UUID` FK → `objects.id` CASCADE |
|
|
||||||
| `pair_id` | `UUID` FK → `pairs.id` CASCADE |
|
|
||||||
|
|
||||||
### Table: `word_categories` (Junction)
|
GET `/api/statements/:id` also returns `positive_word_ids[]` and `negative_word_ids[]`.
|
||||||
| Column | Type |
|
|
||||||
|
**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 | 1–50 |
|
||||||
|
| `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 |
|
| `word_pictures` | `word_id` → `words`, `picture_id` → `pictures` |
|
||||||
| `category_id` | `UUID` FK → `categories.id` CASCADE |
|
| `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
|
## 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
|
### Pictures
|
||||||
|
|
||||||
#### `GET /api/pictures`
|
#### `GET /api/pictures`
|
||||||
List all pictures, newest first.
|
Query params: `status`, `objects_created` (`true`/`false`), `limit` (max 500, default 50), `offset`
|
||||||
|
|
||||||
**Query params:**
|
|
||||||
| Param | Default | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `status` | — | Filter by status (`uploaded`, `published`, `blocked`) |
|
|
||||||
| `limit` | `50` | Max rows (max 500) |
|
|
||||||
| `offset` | `0` | Pagination offset |
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl "$BASE/api/pictures" \
|
curl "$BASE/api/pictures?status=uploaded&objects_created=false" \
|
||||||
-H "Authorization: Bearer $TOKEN"
|
|
||||||
|
|
||||||
# With filter
|
|
||||||
curl "$BASE/api/pictures?status=published&limit=10" \
|
|
||||||
-H "Authorization: Bearer $TOKEN"
|
-H "Authorization: Bearer $TOKEN"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### `GET /api/pictures/:id`
|
#### `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`
|
#### `POST /api/pictures`
|
||||||
Create a new picture entry. Returns the new row with status `uploaded`.
|
Body fields: `generation_prompt`, `generation_timestamp`, `generation_duration_s`, `blurhash`, `design`
|
||||||
|
|
||||||
**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`
|
#### `POST /api/pictures/:id/upload`
|
||||||
Upload an image file to Hetzner and store the public URL in `picture_link`.
|
`multipart/form-data`, field `file`, max 20 MB. Sets `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`
|
#### `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:
|
||||||
|
- `status: "published"` → auto-sets `published_timestamp`
|
||||||
**Auto-behavior:**
|
- `status: "blocked"` → auto-sets `blocked_timestamp`
|
||||||
- Setting `status: "published"` auto-sets `published_timestamp` to now (if not provided)
|
- `objects_created: true` → auto-sets `objects_created_at`
|
||||||
- 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 /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.
|
#### Word links
|
||||||
|
```
|
||||||
```bash
|
POST /api/pictures/:id/words/:wordId
|
||||||
curl -X DELETE "$BASE/api/pictures/<ID>" \
|
DELETE /api/pictures/:id/words/:wordId
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
GET /api/pictures/:id/words
|
||||||
-w "\nHTTP %{http_code}"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Words
|
### Words
|
||||||
|
|
||||||
#### `GET /api/words` — Liste (`?status=requested&limit=50&offset=0`)
|
#### `GET /api/words`
|
||||||
#### `GET /api/words/:id` — Einzelnes Word inkl. `picture_ids` und `category_ids`
|
Query params: `status`, `search` (ILIKE on `titel_de/en/sv`), `limit`, `offset`
|
||||||
#### `POST /api/words` — Neues Word anlegen
|
|
||||||
|
|
||||||
|
#### `GET /api/words/:id`
|
||||||
|
Returns word + `picture_ids[]` + `category_ids[]`
|
||||||
|
|
||||||
|
#### `POST /api/words`
|
||||||
```bash
|
```bash
|
||||||
curl -X POST "$BASE/api/words" \
|
curl -X POST "$BASE/api/words" \
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-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
|
#### `DELETE /api/words/:id`
|
||||||
curl -X PATCH "$BASE/api/words/<ID>" \
|
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
#### Links
|
||||||
-H "Content-Type: application/json" \
|
```
|
||||||
-d '{"status": "published"}'
|
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
|
### Categories
|
||||||
|
|
||||||
#### `GET /api/categories` — Liste (`?status=requested&limit=50&offset=0`)
|
#### `GET /api/categories` — `?status=&limit=&offset=`
|
||||||
#### `GET /api/categories/:id` — Einzelne Kategorie inkl. `word_ids`
|
#### `GET /api/categories/:id` — includes `word_ids[]`
|
||||||
#### `POST /api/categories` — Neue Kategorie anlegen
|
#### `POST /api/categories`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST "$BASE/api/categories" \
|
curl -X POST "$BASE/api/categories" \
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-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`
|
||||||
#### `PATCH /api/categories/:id` — Felder aktualisieren (Status setzt Timestamp automatisch)
|
#### `DELETE /api/categories/:id`
|
||||||
#### `DELETE /api/categories/:id` — Kategorie löschen (entfernt auch alle Verknüpfungen)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Objects
|
### Questions
|
||||||
|
|
||||||
#### `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
|
|
||||||
|
|
||||||
|
#### `GET /api/questions` — `?status=&limit=&offset=`
|
||||||
|
#### `GET /api/questions/:id`
|
||||||
|
#### `POST /api/questions`
|
||||||
```bash
|
```bash
|
||||||
curl -X POST "$BASE/api/objects" \
|
curl -X POST "$BASE/api/questions" \
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-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
|
```bash
|
||||||
# Publizieren
|
curl -X POST "$BASE/api/statements" \
|
||||||
curl -X PATCH "$BASE/api/objects/<ID>" \
|
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"status": "published"}'
|
-d '{
|
||||||
|
"positive_sentence_de": "Das ist ein {{word-uuid}}.",
|
||||||
# Blockieren mit Thema
|
"answer": true,
|
||||||
curl -X PATCH "$BASE/api/objects/<ID>" \
|
"status": "draft"
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
}'
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"status": "blocked", "blocked_topic": "Inhalt ungeeignet"}'
|
|
||||||
```
|
```
|
||||||
|
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/statements/:id`
|
||||||
#### `DELETE /api/objects/:id/words/:wordId` — Word-Verknüpfung lösen
|
|
||||||
#### `POST /api/objects/:id/pictures/:pictureId` — Bild verknüpfen
|
#### Word links
|
||||||
#### `DELETE /api/objects/:id/pictures/:pictureId` — Bild-Verknüpfung lösen
|
```
|
||||||
#### `POST /api/objects/:id/pairs/:pairId` — Pair verknüpfen
|
POST /api/statements/:id/positive-words/:wordId
|
||||||
#### `DELETE /api/objects/:id/pairs/:pairId` — Pair-Verknüpfung lösen
|
DELETE /api/statements/:id/positive-words/:wordId
|
||||||
|
POST /api/statements/:id/negative-words/:wordId
|
||||||
|
DELETE /api/statements/:id/negative-words/:wordId
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pairs
|
### Pairs
|
||||||
|
|
||||||
#### `GET /api/pairs` — Liste (`?status=draft&answer_type=yes_no&limit=50`)
|
#### `GET /api/pairs` — `?status=&answer_type=&limit=&offset=`
|
||||||
#### `GET /api/pairs/:id` — Einzelnes Pair
|
#### `GET /api/pairs/:id`
|
||||||
#### `POST /api/pairs` — Neues Pair anlegen (`answer_type` ist Pflichtfeld)
|
#### `POST /api/pairs`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST "$BASE/api/pairs" \
|
curl -X POST "$BASE/api/pairs" \
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-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 (0–1) 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
|
```bash
|
||||||
# Set once
|
|
||||||
export TOKEN="dev_ccfd6fd149806a900311e253b0c3b5d1e107a68ab5619a5fde61f107ce9098ce"
|
export TOKEN="dev_ccfd6fd149806a900311e253b0c3b5d1e107a68ab5619a5fde61f107ce9098ce"
|
||||||
export BASE="https://hyggecraftery.com/api/snakkimo"
|
export BASE="https://hyggecraftery.com/api/snakkimo"
|
||||||
|
|
||||||
# 1. Create entry
|
# Create + upload a picture
|
||||||
ID=$(curl -s -X POST "$BASE/api/pictures" \
|
ID=$(curl -s -X POST "$BASE/api/pictures" \
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-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'])")
|
| 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" \
|
curl -X POST "$BASE/api/pictures/$ID/upload" \
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-F "file=@/path/to/image.jpg"
|
-F "file=@apple.jpg"
|
||||||
|
|
||||||
# 3. Publish
|
# Create a word
|
||||||
curl -X PATCH "$BASE/api/pictures/$ID" \
|
WID=$(curl -s -X POST "$BASE/api/words" \
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-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)
|
# Create a question pair
|
||||||
curl -X DELETE "$BASE/api/pictures/$ID" \
|
QID=$(curl -s -X POST "$BASE/api/questions" \
|
||||||
-H "Authorization: Bearer $TOKEN"
|
-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
|
## Deployment
|
||||||
|
|
||||||
- **Platform:** Coolify (`snakkimo` project, `production` environment)
|
- **Platform:** Coolify (`snakkimo` project, `production` environment)
|
||||||
|
- **App UUID:** `kmuk5zfthcfov4irjb07y6tx`
|
||||||
- **Gitea repo:** `https://git.hyggecraftery.com/admin/snakkimo-API`
|
- **Gitea repo:** `https://git.hyggecraftery.com/admin/snakkimo-API`
|
||||||
- **Docker:** Node 20 Alpine, port 3000
|
- **Docker:** Node 20 Alpine, port 3000
|
||||||
- **Health check:** Docker-native `HEALTHCHECK` on `GET /health`
|
- **Health check:** `GET /health` (Docker-native HEALTHCHECK)
|
||||||
- **Migrations:** Run automatically on every server start (idempotent)
|
- **Migrations:** Run automatically on every server start (`src/db-migrate.js`) — fully idempotent
|
||||||
|
|||||||
Reference in New Issue
Block a user