Klarbild 1.0.0 — first public release
CI / check (push) Has been cancelled

A self-hosted print studio: images at exact physical sizes, sheet layout with
crop marks, tiling, 1:1 PDF. Everything else — image generation, delivery,
share links, mail, Telegram — is an optional module that is off by default.

This is the neutral public twin of a private tool. Compared to that original:

- optional modules behind KLARBILD_MODULES; the default install is print-only
- English interface with German as a full second language (1,038 strings);
  the English sentence is the translation key
- no built-in accounts — first boot creates one admin from ADMIN_USERNAME /
  ADMIN_PASSWORD, or generates a password and logs it once
- delivery and backup targets are generic SFTP/FTPS endpoints, off by default
- English routes, English identifiers, English comments throughout
- two compose files: print-only by default, everything in the full one
- AGPL-3.0, CI (build, tests, type check, secret scan), CONTRIBUTING, SECURITY

124 tests green, type check clean, verified end to end against a live instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
This commit is contained in:
tillandClaude Opus 5 committed 2026-09-15 14:52:41 +00:00
commit e2481139a1
164 files changed
+27190

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
-- Klarbild — Initial schema (users, recipes, jobs, items, delivery, sharing, Telegram).
-- Applied automatically at startup (numbered SQL files, no ORM magic).
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Users ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
username text UNIQUE NOT NULL,
password_hash text NOT NULL,
role text NOT NULL DEFAULT 'user' CHECK (role IN ('user','admin')),
display_name text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Preset recipes (saved presets) -------------------------------------------
CREATE TABLE IF NOT EXISTS recipes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
tasks jsonb NOT NULL DEFAULT '[]'::jsonb, -- e.g. ["clean","format","deliver"]
output_format text,
orientation text,
crop_mode text,
dpi int NOT NULL DEFAULT 300,
contour_mm numeric,
model_key text,
delivery text NOT NULL DEFAULT 'library' CHECK (delivery IN ('library','remote','both')),
delivery_folder text,
custom_instruction text,
is_default bool NOT NULL DEFAULT false,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Jobs (a batch) --------------------------------------------------------------
CREATE TABLE IF NOT EXISTS jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
origin text NOT NULL DEFAULT 'web' CHECK (origin IN ('web','telegram')),
recipe_snapshot jsonb NOT NULL,
status text NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued','running','paused','done','cancelled')),
total int NOT NULL DEFAULT 0,
done_count int NOT NULL DEFAULT 0,
failed_count int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz
);
-- Folders ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS folders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
delivery_folder text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Items (one image in a job) -----------------------------------------------
CREATE TABLE IF NOT EXISTS items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
job_id uuid REFERENCES jobs(id) ON DELETE CASCADE,
position int NOT NULL DEFAULT 0,
status text NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued','running','done','failed','skipped')),
attempts int NOT NULL DEFAULT 0,
error_message text,
source_path text,
result_path text,
filename text,
output_px text,
source_quality text CHECK (source_quality IN ('original','compressed')),
dpi int,
has_alpha bool NOT NULL DEFAULT false,
model_used text,
prompt_used text,
cost numeric, -- actual cost, from usage.cost
folder_id uuid REFERENCES folders(id) ON DELETE SET NULL,
delivery_status text NOT NULL DEFAULT 'none'
CHECK (delivery_status IN ('none','pending','delivered','failed')),
delivered_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS items_job_idx ON items(job_id);
CREATE INDEX IF NOT EXISTS items_created_idx ON items(created_at DESC);
-- Print jobs ------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS print_jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
note text,
status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','done')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS print_job_items (
print_job_id uuid REFERENCES print_jobs(id) ON DELETE CASCADE,
item_id uuid REFERENCES items(id) ON DELETE CASCADE,
PRIMARY KEY (print_job_id, item_id)
);
-- Share links -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS share_links (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
token text UNIQUE NOT NULL,
scope_type text NOT NULL CHECK (scope_type IN ('selection','folder')),
scope_ref jsonb NOT NULL,
expires_at timestamptz,
password_hash text,
revoked bool NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Telegram ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS telegram_links (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id bigint UNIQUE NOT NULL,
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
default_recipe_id uuid REFERENCES recipes(id) ON DELETE SET NULL,
active bool NOT NULL DEFAULT true,
linked_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS telegram_pairing_codes (
code text PRIMARY KEY,
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL,
used_at timestamptz
);
CREATE TABLE IF NOT EXISTS telegram_drafts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id bigint NOT NULL,
media_group_id text,
file_refs jsonb NOT NULL DEFAULT '[]'::jsonb,
last_received_at timestamptz NOT NULL DEFAULT now(),
status text NOT NULL DEFAULT 'collecting'
CHECK (status IN ('collecting','awaiting_recipe','dispatched','discarded')),
notice_message_id bigint
);
-- Settings (single row) -----------------------------------------------------
CREATE TABLE IF NOT EXISTS settings (
id int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
openrouter_key_enc text,
delivery_host text,
delivery_protocol text CHECK (delivery_protocol IN ('ftps','sftp')),
delivery_port int,
delivery_user text,
delivery_password_enc text,
delivery_base_path text,
default_dpi int NOT NULL DEFAULT 300,
default_crop_mode text NOT NULL DEFAULT 'crop',
concurrency int NOT NULL DEFAULT 2,
cricut_sheet_cm text NOT NULL DEFAULT '17.1x23.5',
monthly_budget numeric,
n8n_webhook_url text,
telegram_bot_token_enc text,
telegram_webhook_secret text
);
-- Models ------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS models (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
model_id text NOT NULL,
label text NOT NULL,
description text,
active bool NOT NULL DEFAULT true,
is_default bool NOT NULL DEFAULT false,
supports_alpha bool NOT NULL DEFAULT false,
sort int NOT NULL DEFAULT 0
);
+2
View File
@@ -0,0 +1,2 @@
-- Telegram: the job knows the chat that triggered it (for replying back).
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS telegram_chat_id bigint;
+2
View File
@@ -0,0 +1,2 @@
-- Configurable default delivery target folder (instead of hardcoded).
ALTER TABLE settings ADD COLUMN IF NOT EXISTS delivery_default_folder text;
@@ -0,0 +1,35 @@
-- Klarbild — Generation modes, thumbnails, storage management, backup mirror.
-- (04) Combining several images + free-text generation, thumbnails for the
-- library, "don't clutter up the server" (retention / deleting sources), a
-- second backup to a backup mirror.
-- Job mode: each = every template on its own (as before), compose = several images
-- + text into ONE new image, generate = pure free text without a template.
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS mode text NOT NULL DEFAULT 'each'
CHECK (mode IN ('each','compose','generate'));
-- Items: several source images (compose) + a shrunk preview image.
ALTER TABLE items ADD COLUMN IF NOT EXISTS source_paths jsonb;
ALTER TABLE items ADD COLUMN IF NOT EXISTS thumb_path text;
-- Preset recipes can pre-set a mode (for default Telegram presets).
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS mode text NOT NULL DEFAULT 'each'
CHECK (mode IN ('each','compose','generate'));
-- Storage management ---------------------------------------------------------
ALTER TABLE settings ADD COLUMN IF NOT EXISTS keep_sources bool NOT NULL DEFAULT true;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS make_thumbnails bool NOT NULL DEFAULT true;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS retention_days int; -- NULL = unlimited
-- Second backup to a backup mirror (SFTP/FTPS, same as the delivery target) ---
ALTER TABLE settings ADD COLUMN IF NOT EXISTS mirror_enabled bool NOT NULL DEFAULT false;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS mirror_host text;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS mirror_protocol text CHECK (mirror_protocol IN ('ftps','sftp'));
ALTER TABLE settings ADD COLUMN IF NOT EXISTS mirror_port int;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS mirror_user text;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS mirror_password_enc text;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS mirror_base_path text;
-- Mirror status per item (for visibility/retrying).
ALTER TABLE items ADD COLUMN IF NOT EXISTS mirror_status text NOT NULL DEFAULT 'none'
CHECK (mirror_status IN ('none','pending','mirrored','failed'));
+5
View File
@@ -0,0 +1,5 @@
-- Telegram: combine/generate — caption as description + an in-between status.
ALTER TABLE telegram_drafts ADD COLUMN IF NOT EXISTS caption text;
ALTER TABLE telegram_drafts DROP CONSTRAINT IF EXISTS telegram_drafts_status_check;
ALTER TABLE telegram_drafts ADD CONSTRAINT telegram_drafts_status_check
CHECK (status IN ('collecting','awaiting_recipe','awaiting_compose_text','dispatched','discarded'));
+4
View File
@@ -0,0 +1,4 @@
-- Alternatives/regeneration: group several results under the same origin.
-- variant_of points at the "root" item (the first version); alternatives share it.
ALTER TABLE items ADD COLUMN IF NOT EXISTS variant_of uuid REFERENCES items(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS items_variant_idx ON items(variant_of);
+21
View File
@@ -0,0 +1,21 @@
-- Several delivery targets (FTP/SFTP sources), e.g. a second delivery target
-- folder or a different FTP server. Preset recipes can hardcode one target.
CREATE TABLE IF NOT EXISTS delivery_targets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
protocol text CHECK (protocol IN ('ftps','sftp')),
host text,
port int,
username text,
password_enc text,
base_path text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Preset → fixed target (NULL = the default delivery target from settings).
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS delivery_target_id uuid
REFERENCES delivery_targets(id) ON DELETE SET NULL;
-- Folder → fixed target (optional).
ALTER TABLE folders ADD COLUMN IF NOT EXISTS delivery_target_id uuid
REFERENCES delivery_targets(id) ON DELETE SET NULL;
@@ -0,0 +1,19 @@
-- Backup to any target, metadata sidecar files, API token for external access (MCP).
-- Any extra target can be flagged as a backup target (receives ALL results).
ALTER TABLE delivery_targets ADD COLUMN IF NOT EXISTS is_backup bool NOT NULL DEFAULT false;
-- Send metadata along as a companion .md file (prompt, file name, model, …).
ALTER TABLE settings ADD COLUMN IF NOT EXISTS metadata_sidecar bool NOT NULL DEFAULT false;
-- API token for programmatic access (Klarbild MCP / automations).
ALTER TABLE settings ADD COLUMN IF NOT EXISTS api_token text;
-- Backup status per target and item (for "backed up to several targets").
CREATE TABLE IF NOT EXISTS item_backups (
item_id uuid REFERENCES items(id) ON DELETE CASCADE,
target text NOT NULL, -- 'mirror' | delivery_target UUID
status text NOT NULL DEFAULT 'mirrored' CHECK (status IN ('mirrored','failed')),
at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (item_id, target)
);
+18
View File
@@ -0,0 +1,18 @@
-- Access rights, private sessions, NSFW switch.
-- Visibility of the library + privacy.
ALTER TABLE settings ADD COLUMN IF NOT EXISTS library_visibility text NOT NULL DEFAULT 'own'
CHECK (library_visibility IN ('own','shared')); -- own = each user sees only their own; shared = everyone sees everything
ALTER TABLE settings ADD COLUMN IF NOT EXISTS anonymous_generations bool NOT NULL DEFAULT false;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS private_allowed bool NOT NULL DEFAULT false;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS allow_nsfw bool NOT NULL DEFAULT false;
-- Per user: force private sessions.
ALTER TABLE users ADD COLUMN IF NOT EXISTS private_forced bool NOT NULL DEFAULT false;
-- Private jobs: generate only, then forget (not in the library, no delivery/backup,
-- no stored prompt, sources deleted immediately, result removed after a short time).
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS private bool NOT NULL DEFAULT false;
-- Flag models as NSFW-capable (selectable/visible only while allow_nsfw is on).
ALTER TABLE models ADD COLUMN IF NOT EXISTS nsfw bool NOT NULL DEFAULT false;
+4
View File
@@ -0,0 +1,4 @@
-- Color tags, as in the delivery target (red / orange / green / final).
ALTER TABLE items ADD COLUMN IF NOT EXISTS color_tag text
CHECK (color_tag IN ('red','orange','green','final'));
CREATE INDEX IF NOT EXISTS items_color_tag_idx ON items(color_tag);
+4
View File
@@ -0,0 +1,4 @@
-- Metadata sidecar file (.md) controllable per upload source instead of globally.
ALTER TABLE settings ADD COLUMN IF NOT EXISTS delivery_metadata_sidecar bool NOT NULL DEFAULT false;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS mirror_metadata_sidecar bool NOT NULL DEFAULT false;
ALTER TABLE delivery_targets ADD COLUMN IF NOT EXISTS metadata_sidecar bool NOT NULL DEFAULT false;
@@ -0,0 +1,11 @@
-- Jobs may now also be "failed" (all items failed) → shown red in the overview.
ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_status_check;
ALTER TABLE jobs ADD CONSTRAINT jobs_status_check
CHECK (status IN ('queued','running','paused','done','cancelled','failed'));
-- Output format of the image file: global (settings) + an optional preset override.
-- "png" = lossless (default, needed for transparency), "jpg" = small, friendly to the delivery target.
ALTER TABLE settings ADD COLUMN IF NOT EXISTS output_ext text NOT NULL DEFAULT 'png'
CHECK (output_ext IN ('png','jpg'));
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS output_ext text
CHECK (output_ext IN ('png','jpg'));
+14
View File
@@ -0,0 +1,14 @@
-- Presets for passport photos/print sheets ("daycare set", "8× passport photo") ---
CREATE TABLE IF NOT EXISTS print_presets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
config jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS print_presets_name_idx ON print_presets(name);
-- Telegram: state "waiting for print selection" -----------------------------
ALTER TABLE telegram_drafts DROP CONSTRAINT IF EXISTS telegram_drafts_status_check;
ALTER TABLE telegram_drafts ADD CONSTRAINT telegram_drafts_status_check
CHECK (status IN ('collecting','awaiting_recipe','awaiting_compose_text','awaiting_print','dispatched','discarded'));
+71
View File
@@ -0,0 +1,71 @@
-- Sending email, password reset, and share links ---------------------------
-- All forward-only and idempotent, like the other migrations.
-- SMTP access. The password is stored AES-256-GCM encrypted, like the other
-- secrets (src/lib/crypto.ts) — never in plain text.
ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_host text;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_port integer NOT NULL DEFAULT 465;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_secure boolean NOT NULL DEFAULT true;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_user text;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_password text;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_from text;
-- How long a new share link is valid by default (0 = unlimited).
ALTER TABLE settings ADD COLUMN IF NOT EXISTS share_default_days integer NOT NULL DEFAULT 30;
-- Email on the user account: for "forgot password" and optional notices.
ALTER TABLE users ADD COLUMN IF NOT EXISTS email text;
ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_jobs boolean NOT NULL DEFAULT false;
CREATE UNIQUE INDEX IF NOT EXISTS users_email_idx ON users (lower(email)) WHERE email IS NOT NULL;
-- Password reset. Only the **hash** of the token is stored — reading the
-- database alone is not enough to take over an account.
CREATE TABLE IF NOT EXISTS password_resets (
token_hash text PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL,
used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS password_resets_user_idx ON password_resets(user_id);
-- Share links: one image or a collection, publicly reachable at /s/<slug>.
CREATE TABLE IF NOT EXISTS shares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text UNIQUE NOT NULL,
kind text NOT NULL DEFAULT 'item' CHECK (kind IN ('item','collection')),
title text,
note text,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
expires_at timestamptz,
revoked_at timestamptz,
allow_download boolean NOT NULL DEFAULT true,
-- Optional passphrase (argon2), so a forwarded link alone is not enough.
password_hash text,
views integer NOT NULL DEFAULT 0,
downloads integer NOT NULL DEFAULT 0,
last_seen_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS shares_created_by_idx ON shares(created_by);
CREATE TABLE IF NOT EXISTS share_items (
share_id uuid NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
item_id uuid NOT NULL REFERENCES items(id) ON DELETE CASCADE,
position integer NOT NULL DEFAULT 0,
PRIMARY KEY (share_id, item_id)
);
CREATE INDEX IF NOT EXISTS share_items_item_idx ON share_items(item_id);
-- Accesses per link. The IP is stored only **hashed** (salted with the server's
-- secret): this lets you count returning visitors without identifying anyone
-- or recovering the address.
CREATE TABLE IF NOT EXISTS share_views (
id bigserial PRIMARY KEY,
share_id uuid NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
kind text NOT NULL DEFAULT 'view' CHECK (kind IN ('view','download')),
ip_hash text,
user_agent text,
at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS share_views_share_idx ON share_views(share_id, at DESC);
+37
View File
@@ -0,0 +1,37 @@
-- Make sessions revocable server-side ---------------------------------------
-- Until now the session cookie was only HMAC-signed: whoever had it once got
-- in with it indefinitely — even after the password was reset or the role
-- was taken away. The very step meant to rescue a hijacked account therefore
-- did not help.
--
-- `session_epoch` is incremented on every password change and every role
-- change. The cookie carries the value along; once it no longer matches, the
-- cookie is no longer valid. A counter instead of a timestamp, because it
-- knows no clock-skew inaccuracies.
ALTER TABLE users ADD COLUMN IF NOT EXISTS session_epoch integer NOT NULL DEFAULT 0;
-- For rename assignment: `renames.ts` checks, for every finished image,
-- whether a name is already taken — without an index that was a full table scan.
CREATE INDEX IF NOT EXISTS items_filename_idx ON items(filename);
-- Cleanup: both tables grew without bound.
CREATE INDEX IF NOT EXISTS share_views_at_idx ON share_views(at);
CREATE INDEX IF NOT EXISTS password_resets_expires_idx ON password_resets(expires_at);
-- Counter for distinct visitors per share link. It used to be computed on every
-- load of the admin page via `count(DISTINCT ip_hash)` over share_views — that
-- got slow as the table grew, and made the page attackable, since anyone with
-- the link could generate an unlimited number of rows.
ALTER TABLE shares ADD COLUMN IF NOT EXISTS visitor integer NOT NULL DEFAULT 0;
-- Only one row per link, visitor and hour: without this limit, a recipient
-- (or an <img> tag on some page) could fill the table without bound and run
-- the disk out of space.
--
-- The hour value is supplied at write time instead of computed in the index:
-- `date_trunc` on a timestamptz depends on the timezone setting and is
-- therefore not immutable enough for an index.
ALTER TABLE share_views ADD COLUMN IF NOT EXISTS hour text;
CREATE UNIQUE INDEX IF NOT EXISTS share_views_once_per_hour_idx
ON share_views (share_id, kind, ip_hash, hour)
WHERE ip_hash IS NOT NULL AND hour IS NOT NULL;
+39
View File
@@ -0,0 +1,39 @@
-- Print history: what was printed last --------------------------------------
--
-- Only the **recipe** is stored, not the result: the sheet settings, the
-- references to the source images, and a few key figures. An A3 print sheet
-- as a PDF is quickly 20–80 MB; a few dozen prints would fill the disk, and
-- with files that can be regenerated in seconds at that. The sheet is simply
-- recomputed on reprint — deterministic, from the same sources.
--
-- Row size: roughly 1–3 KB per entry. A thousand prints therefore cost a few
-- megabytes instead of many gigabytes.
CREATE TABLE IF NOT EXISTS print_runs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
-- Where the print came from: 'web' | 'telegram' | 'mcp' | 'api'
origin text NOT NULL DEFAULT 'web',
-- Human-readable short form for the list ("13 × 18 cm ×1, 35 × 45 mm ×8").
summary text,
paper text, -- "A4 · 210 × 297 mm"
pages integer NOT NULL DEFAULT 1,
pieces integer NOT NULL DEFAULT 1,
bytes integer, -- size of the generated PDF, informational only
-- The full request that the sheet can be regenerated from.
-- Deliberately the request body and not a bespoke format: what is stored
-- here can be sent back to /api/print/sheet unchanged.
config jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS print_runs_user_idx ON print_runs(created_by, created_at DESC);
CREATE INDEX IF NOT EXISTS print_runs_created_idx ON print_runs(created_at DESC);
-- Which library images went into a print. Only for images from the
-- library — individually uploaded files have no durable identifier and are
-- gone anyway once the sources are cleaned up.
CREATE TABLE IF NOT EXISTS print_run_items (
run_id uuid NOT NULL REFERENCES print_runs(id) ON DELETE CASCADE,
item_id uuid NOT NULL REFERENCES items(id) ON DELETE CASCADE,
PRIMARY KEY (run_id, item_id)
);
CREATE INDEX IF NOT EXISTS print_run_items_item_idx ON print_run_items(item_id);