feat(aether): initial commit – self-hosted Cloud, Phasen 1–7

Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

@@ -0,0 +1,12 @@
DROP TRIGGER IF EXISTS trg_files_updated_at ON files;
DROP TRIGGER IF EXISTS trg_folders_updated_at ON folders;
DROP TRIGGER IF EXISTS trg_users_updated_at ON users;
DROP FUNCTION IF EXISTS update_updated_at();
DROP TABLE IF EXISTS file_versions;
DROP TABLE IF EXISTS files;
DROP TABLE IF EXISTS folders;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;
DROP EXTENSION IF EXISTS "pgcrypto";
+103
View File
@@ -0,0 +1,103 @@
-- Aether – Initial schema
-- Phase 0: users, sessions, folders, files (no email)
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ── Users ─────────────────────────────────────────────────────────────────────
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL, -- Argon2id hash
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
quota_bytes BIGINT NOT NULL DEFAULT 10737418240, -- 10 GB default
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── Sessions ──────────────────────────────────────────────────────────────────
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
data BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
-- ── Folders ───────────────────────────────────────────────────────────────────
CREATE TABLE folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (owner_id, parent_id, name)
);
CREATE INDEX idx_folders_owner ON folders(owner_id);
CREATE INDEX idx_folders_parent ON folders(parent_id);
-- ── Files ─────────────────────────────────────────────────────────────────────
CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
folder_id UUID REFERENCES folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size BIGINT NOT NULL DEFAULT 0,
checksum TEXT NOT NULL, -- SHA-256 hex
storage_key TEXT NOT NULL, -- path within blob storage
version INTEGER NOT NULL DEFAULT 1,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_files_owner ON files(owner_id);
CREATE INDEX idx_files_folder ON files(folder_id);
CREATE INDEX idx_files_deleted ON files(is_deleted);
CREATE INDEX idx_files_storage ON files(storage_key);
-- Partial unique index: no two non-deleted files can share name+folder for the same owner.
CREATE UNIQUE INDEX idx_files_unique_name
ON files(owner_id, folder_id, name)
WHERE NOT is_deleted;
-- ── File versions ─────────────────────────────────────────────────────────────
-- Previous versions of files (kept for Soft-Delete + versioning).
CREATE TABLE file_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
size BIGINT NOT NULL,
checksum TEXT NOT NULL,
storage_key TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_file_versions_file ON file_versions(file_id);
-- ── Updated_at trigger ────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_folders_updated_at
BEFORE UPDATE ON folders
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_files_updated_at
BEFORE UPDATE ON files
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS uploads;
DROP TABLE IF EXISTS share_links;
ALTER TABLE files DROP COLUMN IF EXISTS has_thumbnail;
@@ -0,0 +1,35 @@
-- Phase 2: share links, resumable uploads, thumbnails
ALTER TABLE files ADD COLUMN has_thumbnail BOOLEAN NOT NULL DEFAULT FALSE;
-- ── Share links ───────────────────────────────────────────────────────────────
CREATE TABLE share_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
password_hash TEXT, -- Argon2id, NULL = no password
expires_at TIMESTAMPTZ, -- NULL = never
download_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_share_links_token ON share_links(token);
CREATE INDEX idx_share_links_owner ON share_links(owner_id);
CREATE INDEX idx_share_links_file ON share_links(file_id);
-- ── Resumable uploads ────────────────────────────────────────────────────────
CREATE TABLE uploads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
total_size BIGINT NOT NULL,
uploaded BIGINT NOT NULL DEFAULT 0,
storage_key TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours'
);
CREATE INDEX idx_uploads_owner ON uploads(owner_id);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS favorites;
@@ -0,0 +1,11 @@
CREATE TABLE favorites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
folder_id UUID NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
is_expanded BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, folder_id)
);
CREATE INDEX idx_favorites_user ON favorites(user_id);
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS group_members;
DROP TABLE IF EXISTS groups;
DELETE FROM settings WHERE key IN ('cloud_name','primary_color','accent_color','logo_storage_key');
ALTER TABLE users DROP COLUMN IF EXISTS display_name;
@@ -0,0 +1,35 @@
-- Phase 3: Admin panel – groups, settings, user display names
-- ── Users: display name ───────────────────────────────────────────────────────
ALTER TABLE users ADD COLUMN IF NOT EXISTS display_name TEXT NOT NULL DEFAULT '';
-- ── Groups ───────────────────────────────────────────────────────────────────
CREATE TABLE groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE group_members (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (group_id, user_id)
);
CREATE INDEX idx_group_members_user ON group_members(user_id);
-- ── Settings (key-value, admin-managed) ───────────────────────────────────────
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Default settings
INSERT INTO settings (key, value) VALUES
('cloud_name', 'Aether'),
('primary_color', '#6366f1'),
('accent_color', '#8b5cf6'),
('logo_storage_key', '');
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS oidc_sub;
+3
View File
@@ -0,0 +1,3 @@
-- OIDC subject for linking OIDC provider accounts to local users.
-- NULL means the user only has a local password login.
ALTER TABLE users ADD COLUMN oidc_sub TEXT UNIQUE;
@@ -0,0 +1 @@
ALTER TABLE files DROP COLUMN IF EXISTS versioning_enabled;
@@ -0,0 +1,11 @@
-- Versionierung ist ab hier eine Opt-in-Einstellung pro Datei.
-- Standard: aus. Ohne den Schalter würde jede Überschreibung eine weitere
-- Fassung aufheben und den Speicher unbemerkt volllaufen lassen.
ALTER TABLE files
ADD COLUMN versioning_enabled BOOLEAN NOT NULL DEFAULT false;
-- Dateien, die bereits Fassungen haben, behalten ihr bisheriges Verhalten –
-- sonst würde ein bestehender Verlauf stillschweigend eingefroren.
UPDATE files f
SET versioning_enabled = true
WHERE EXISTS (SELECT 1 FROM file_versions v WHERE v.file_id = f.id);
@@ -0,0 +1,3 @@
ALTER TABLE users
DROP COLUMN IF EXISTS primary_color,
DROP COLUMN IF EXISTS accent_color;
@@ -0,0 +1,9 @@
-- Persönliche Farbpalette pro Nutzer. NULL bedeutet: es gilt die globale
-- Einstellung des Administrators.
--
-- Bis hierher zeigte die Profilseite zwar Farbwähler, schrieb damit aber in die
-- globalen Einstellungen – für normale Nutzer scheiterte das an der
-- Admin-Prüfung, beim Administrator färbte es die ganze Cloud um.
ALTER TABLE users
ADD COLUMN primary_color TEXT,
ADD COLUMN accent_color TEXT;
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS use_custom_theme;
@@ -0,0 +1,13 @@
-- Schalter zwischen persönlicher und globaler Palette.
--
-- Bisher bedeutete "keine eigene Farbe" = globale Einstellung. Zum Zurückschalten
-- musste man die eigenen Werte löschen und war sie damit los. Der Schalter trennt
-- beides: die Farben bleiben gespeichert, nur ihre Anwendung wird umgelegt.
ALTER TABLE users
ADD COLUMN use_custom_theme BOOLEAN NOT NULL DEFAULT false;
-- Wer bereits eine eigene Farbe gesetzt hat, hat sie sichtbar verwendet –
-- das Verhalten soll durch die Migration nicht kippen.
UPDATE users
SET use_custom_theme = true
WHERE primary_color IS NOT NULL OR accent_color IS NOT NULL;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS app_passwords;
@@ -0,0 +1,15 @@
-- App-Passwörter: widerrufbare Zugangsdaten für WebDAV-/Sync-Clients, damit das
-- Hauptpasswort nicht auf Geräten liegt, die man verlieren kann.
CREATE TABLE app_passwords (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
-- SHA-256 des Tokens. Kein Argon2: das Token ist serverseitig zufällig und
-- hochentropisch, also nicht ratbar – und WebDAV authentifiziert jeden
-- einzelnen Request, wo Argon2 pro Aufruf zu teuer wäre.
token_hash TEXT NOT NULL UNIQUE,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_app_passwords_user ON app_passwords(user_id);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS audit_log;
@@ -0,0 +1,27 @@
-- Audit-Log: wer hat was wann getan. Zweck ist die Nachvollziehbarkeit nach
-- einem Vorfall – deshalb muss ein Eintrag auch dann noch lesbar sein, wenn der
-- handelnde oder betroffene Account längst gelöscht ist.
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- ON DELETE SET NULL statt CASCADE: sonst räumte das Löschen eines Users
-- genau die Spuren weg, die man danach untersuchen will.
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
-- Denormalisiert, damit der Name den gelöschten Account überlebt.
actor_name TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT,
target_id TEXT,
target_name TEXT,
ip TEXT,
detail TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Die Admin-Ansicht liest immer neueste zuerst, oft gefiltert nach Aktion oder
-- handelndem Konto.
CREATE INDEX idx_audit_log_created ON audit_log (created_at DESC);
CREATE INDEX idx_audit_log_actor ON audit_log (actor_id, created_at DESC);
CREATE INDEX idx_audit_log_action ON audit_log (action, created_at DESC);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS acl_entries;
@@ -0,0 +1,51 @@
-- Interne Freigaben: Ordner oder Datei an einen anderen Nutzer oder eine Gruppe
-- geben, ohne dass die Bytes umziehen. Der Empfänger sieht sie virtuell unter
-- „Geteilt mit mir"; auf der Platte bleibt alles beim Eigentümer.
--
-- Abweichung vom Projektplan: dort war eine polymorphe Tabelle skizziert
-- (subject_type + subject_id als Text/UUID ohne Fremdschlüssel). Hier stattdessen
-- vier nullbare Fremdschlüssel mit CHECK-Bedingung. Grund: polymorph kann die
-- Datenbank nichts erzwingen – ein gelöschter Nutzer oder Ordner hinterließe
-- Einträge, die auf nichts mehr zeigen, und aus so einer Leiche später eine
-- Freigabe für einen neu vergebenen Namen zu machen wäre ein Sicherheitsloch.
-- Mit echten Fremdschlüsseln räumt ON DELETE CASCADE das von selbst auf.
CREATE TABLE acl_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Empfänger: genau eines von beiden.
subject_user_id UUID REFERENCES users(id) ON DELETE CASCADE,
subject_group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
-- Freigegebene Ressource: genau eines von beiden.
resource_folder_id UUID REFERENCES folders(id) ON DELETE CASCADE,
resource_file_id UUID REFERENCES files(id) ON DELETE CASCADE,
permission TEXT NOT NULL CHECK (permission IN ('read', 'write')),
-- Wer die Freigabe erteilt hat. SET NULL statt CASCADE: die Freigabe soll
-- nicht verschwinden, nur weil das Konto des Erteilers gelöscht wurde.
granted_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT acl_one_subject CHECK (num_nonnulls(subject_user_id, subject_group_id) = 1),
CONSTRAINT acl_one_resource CHECK (num_nonnulls(resource_folder_id, resource_file_id) = 1)
);
-- Zwei Freigaben derselben Ressource an denselben Empfänger wären mehrdeutig:
-- welches Recht gilt dann? Partielle Unique-Indizes, weil NULL in einem
-- gewöhnlichen UNIQUE nicht mit sich selbst kollidiert.
CREATE UNIQUE INDEX idx_acl_user_folder ON acl_entries (subject_user_id, resource_folder_id)
WHERE subject_user_id IS NOT NULL AND resource_folder_id IS NOT NULL;
CREATE UNIQUE INDEX idx_acl_user_file ON acl_entries (subject_user_id, resource_file_id)
WHERE subject_user_id IS NOT NULL AND resource_file_id IS NOT NULL;
CREATE UNIQUE INDEX idx_acl_group_folder ON acl_entries (subject_group_id, resource_folder_id)
WHERE subject_group_id IS NOT NULL AND resource_folder_id IS NOT NULL;
CREATE UNIQUE INDEX idx_acl_group_file ON acl_entries (subject_group_id, resource_file_id)
WHERE subject_group_id IS NOT NULL AND resource_file_id IS NOT NULL;
-- Die Zugriffsprüfung läuft bei jedem Request; sie fragt nach Ressource.
CREATE INDEX idx_acl_folder ON acl_entries (resource_folder_id) WHERE resource_folder_id IS NOT NULL;
CREATE INDEX idx_acl_file ON acl_entries (resource_file_id) WHERE resource_file_id IS NOT NULL;
-- „Geteilt mit mir" fragt nach Empfänger.
CREATE INDEX idx_acl_subject_user ON acl_entries (subject_user_id) WHERE subject_user_id IS NOT NULL;
CREATE INDEX idx_acl_subject_group ON acl_entries (subject_group_id) WHERE subject_group_id IS NOT NULL;
@@ -0,0 +1,18 @@
DROP INDEX IF EXISTS idx_files_group;
DROP INDEX IF EXISTS idx_folders_group;
DROP INDEX IF EXISTS idx_folders_group_root_name;
DROP INDEX IF EXISTS idx_folders_group_name;
ALTER TABLE groups DROP COLUMN IF EXISTS quota_bytes;
ALTER TABLE files DROP CONSTRAINT IF EXISTS files_one_principal;
ALTER TABLE folders DROP CONSTRAINT IF EXISTS folders_one_principal;
-- Erst die Gruppendaten entfernen, sonst scheitert das NOT NULL unten.
DELETE FROM files WHERE group_id IS NOT NULL;
DELETE FROM folders WHERE group_id IS NOT NULL;
ALTER TABLE files DROP COLUMN IF EXISTS group_id;
ALTER TABLE folders DROP COLUMN IF EXISTS group_id;
ALTER TABLE files ALTER COLUMN owner_id SET NOT NULL;
ALTER TABLE folders ALTER COLUMN owner_id SET NOT NULL;
@@ -0,0 +1,35 @@
-- Gruppenordner: ein Ordner, der keinem einzelnen Konto gehört, sondern einer
-- Gruppe. Wer Mitglied ist, sieht ihn; wer die Gruppe verlässt, nicht mehr.
--
-- Ordner und Dateien gehören ab jetzt entweder einem Nutzer ODER einer Gruppe,
-- nie beidem und nie keinem. Deshalb wird owner_id nullbar und bekommt eine
-- CHECK-Bedingung als Gegengewicht – ohne die könnte ein Datensatz herrenlos
-- werden und wäre über keine Abfrage mehr erreichbar.
ALTER TABLE folders ALTER COLUMN owner_id DROP NOT NULL;
ALTER TABLE files ALTER COLUMN owner_id DROP NOT NULL;
ALTER TABLE folders ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE;
ALTER TABLE files ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE;
ALTER TABLE folders ADD CONSTRAINT folders_one_principal
CHECK (num_nonnulls(owner_id, group_id) = 1);
ALTER TABLE files ADD CONSTRAINT files_one_principal
CHECK (num_nonnulls(owner_id, group_id) = 1);
-- Gruppen bekommen ein eigenes Kontingent. Die Alternative – „zählt beim
-- Ersteller" – wäre für die Mitglieder nicht nachvollziehbar: Der Ordner wäre
-- plötzlich voll, weil jemand anders privat etwas hochgeladen hat.
ALTER TABLE groups ADD COLUMN quota_bytes BIGINT NOT NULL DEFAULT 10737418240;
-- Die bestehende UNIQUE (owner_id, parent_id, name) greift für Gruppenordner
-- nicht: In SQL kollidiert NULL nicht mit NULL, zwei Gruppenordner gleichen
-- Namens rutschten also durch.
CREATE UNIQUE INDEX idx_folders_group_name ON folders (group_id, parent_id, name)
WHERE group_id IS NOT NULL AND parent_id IS NOT NULL;
-- Auf oberster Ebene ist parent_id NULL, das braucht einen eigenen Index.
CREATE UNIQUE INDEX idx_folders_group_root_name ON folders (group_id, name)
WHERE group_id IS NOT NULL AND parent_id IS NULL;
CREATE INDEX idx_folders_group ON folders (group_id) WHERE group_id IS NOT NULL;
CREATE INDEX idx_files_group ON files (group_id) WHERE group_id IS NOT NULL;
@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS idx_folders_name_trgm;
DROP INDEX IF EXISTS idx_files_name_trgm;
-- Die Erweiterung bleibt bewusst stehen: Sie wieder zu entfernen würde alles
-- mitreißen, was sonst noch auf ihr aufbaut, und sie kostet nichts.
@@ -0,0 +1,12 @@
-- Namenssuche über Dateien und Ordner.
--
-- pg_trgm statt einer Volltextsuche: Gesucht wird nach Namensteilen
-- ("rechn" findet "Rechnung_2026.pdf"), und eine Volltextsuche zerlegt in
-- Wörter, findet also gerade Teilwörter nicht. Volltext über den *Inhalt*
-- kommt in Phase 6 dazu – das ist eine andere Aufgabe.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- GIN mit Trigrammen macht ILIKE '%…%' indizierbar. Ohne den Index läuft jede
-- Suche als vollständiger Tabellendurchlauf.
CREATE INDEX idx_files_name_trgm ON files USING gin (name gin_trgm_ops);
CREATE INDEX idx_folders_name_trgm ON folders USING gin (name gin_trgm_ops);
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS ocr_jobs;
DROP TABLE IF EXISTS file_text;
@@ -0,0 +1,63 @@
-- Volltext über den *Inhalt* – die zweite Hälfte der Suche.
--
-- Migration 000013 machte Datei*namen* durchsuchbar. Das reicht, solange
-- Dateien sprechende Namen haben. Ein Scanner nennt seine Ablage aber
-- "scan_0042.pdf", und genau die Belege sollen auffindbar werden.
-- ── Der erkannte Text ─────────────────────────────────────────────────────────
-- Eigene Tabelle statt einer Spalte an files: Der Text ist um Größenordnungen
-- länger als der Rest der Zeile, und files wird bei jedem Auflisten gelesen.
-- Postgres lagert lange Werte zwar aus (TOAST), aber die tsvector-Spalte und
-- ihr GIN-Index gehörten dann trotzdem an eine Tabelle, die ständig in
-- Bewegung ist.
CREATE TABLE file_text (
file_id UUID PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
content TEXT NOT NULL,
-- Woher der Text stammt. 'layer' = das PDF hatte bereits eine Textebene
-- (pdftotext, verlustfrei); 'ocr' = aus dem Bild erkannt, kann Fehler
-- enthalten. Der Unterschied ist für die Fehlersuche wichtig: Findet die
-- Suche ein Wort nicht, sagt die Herkunft, ob OCR es verlesen hat.
source TEXT NOT NULL CHECK (source IN ('layer', 'ocr')),
-- Die Sprachen, mit denen erkannt wurde ("deu+eng"). Bei 'layer' leer.
lang TEXT NOT NULL DEFAULT '',
-- Zeichenzahl des Originals vor dem Kürzen – siehe maxTextBytes im Worker.
chars INTEGER NOT NULL DEFAULT 0,
pages INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Berechnete Spalte statt eines Triggers: Sie kann nie mit dem Inhalt
-- auseinanderlaufen, weil Postgres sie bei jedem Schreiben selbst füllt.
--
-- 'german' fest verdrahtet, nicht 'simple': Ohne Stammformen fände
-- "Rechnung" die "Rechnungen" nicht. Deutsch ist die Sprache der Belege,
-- um die es hier geht; englische Wörter darin verlieren nur ihre
-- Stammformbildung, sie bleiben wörtlich auffindbar. Eine Änderung wäre
-- eine Migration – der Preis dafür, dass der Ausdruck IMMUTABLE sein muss.
tsv tsvector GENERATED ALWAYS AS (to_tsvector('german', content)) STORED
);
CREATE INDEX idx_file_text_tsv ON file_text USING gin (tsv);
-- ── Die Warteschlange ─────────────────────────────────────────────────────────
-- Warum eine Tabelle und nicht einfach ein Goroutine-Aufruf wie bei den
-- Vorschaubildern: OCR dauert Sekunden bis Minuten. Startet der Server in
-- dieser Zeit neu – Update, Absturz, Neustart des Wirts –, ist ein Auftrag im
-- Speicher verloren, und die Datei bleibt für immer unauffindbar, ohne dass es
-- jemand merkt. Bei einem Vorschaubild fällt das auf; bei fehlendem Text nicht.
CREATE TABLE ocr_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Ein Auftrag je Datei. Wird dieselbe Datei erneut hochgeladen, ersetzt der
-- neue Auftrag den alten (ON CONFLICT), statt sich daneben zu stapeln.
file_id UUID NOT NULL UNIQUE REFERENCES files(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'running', 'done', 'failed')),
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Der Arbeiter fragt nur nach offenen Aufträgen. Ein Teilindex bleibt klein,
-- auch wenn sich zehntausend erledigte Zeilen ansammeln.
CREATE INDEX idx_ocr_jobs_pending ON ocr_jobs(created_at)
WHERE status IN ('pending', 'running');
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_files_document_date;
ALTER TABLE files DROP COLUMN IF EXISTS document_date;
DROP TABLE IF EXISTS file_tags;
DROP TABLE IF EXISTS tags;
+75
View File
@@ -0,0 +1,75 @@
-- Etiketten für Dateien.
--
-- Warum neben Ordnern und Volltext noch ein drittes Ordnungsmittel: Ordner
-- beantworten „wann" (Rechnungen/2026), der Volltext beantwortet „wo steht
-- dieses Wort". Beides beantwortet **nicht**, wofür ein Beleg gebraucht wird –
-- „absetzbar" steht in keiner Rechnung, das ist eine Einschätzung des Menschen.
--
-- Und der Fall, an dem Ordner scheitern: Der Kaufbeleg eines Notebooks ist
-- zugleich Garantienachweis *und* absetzbar. In einen Ordner passt er nur
-- einmal; Etiketten trägt dieselbe Datei beliebig viele.
-- ── Die Etiketten selbst ─────────────────────────────────────────────────────
-- Prinzipal-Modell wie bei files und folders: Ein Etikett gehört entweder einem
-- Konto oder einer Gruppe, nie beidem und nie keinem.
--
-- Der Grund für die Gruppenvariante: In einem Gruppenordner arbeiten mehrere an
-- denselben Dokumenten. Hätte dort jeder seine eigenen Etiketten, sortierte
-- jeder dieselben Dateien für sich neu ein und niemand sähe die Arbeit des
-- anderen – der Sinn eines geteilten Ordners wäre dahin.
CREATE TABLE tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID REFERENCES users(id) ON DELETE CASCADE,
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
name TEXT NOT NULL,
-- Hex-Farbe für den Aufkleber. Leer heißt: die Oberfläche wählt eine aus
-- dem Namen, damit ein neues Etikett nicht erst eingefärbt werden muss.
color TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT tags_one_principal CHECK (
(owner_id IS NOT NULL AND group_id IS NULL) OR
(owner_id IS NULL AND group_id IS NOT NULL)
),
CONSTRAINT tags_name_not_empty CHECK (length(btrim(name)) > 0)
);
-- Namen sind je Prinzipal eindeutig, und zwar **ohne Rücksicht auf
-- Groß-/Kleinschreibung**. Sonst stehen nach drei Monaten "Stadtwerke",
-- "stadtwerke" und "STADTWERKE" nebeneinander – genau der Zerfall, den
-- Etiketten gegenüber freien Ordnernamen verhindern sollen.
CREATE UNIQUE INDEX idx_tags_owner_name ON tags (owner_id, lower(btrim(name)))
WHERE owner_id IS NOT NULL;
CREATE UNIQUE INDEX idx_tags_group_name ON tags (group_id, lower(btrim(name)))
WHERE group_id IS NOT NULL;
-- ── Die Zuordnung ────────────────────────────────────────────────────────────
-- Eine reine Verknüpfungstabelle. Nichts davon wird in die Datei geschrieben:
-- Das Dokument bleibt Byte für Byte, wie es hochgeladen wurde – dieselbe Regel
-- wie beim erkannten Text (siehe internal/documents).
CREATE TABLE file_tags (
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
-- Wer es angehängt hat. In einem Gruppenordner sonst nicht nachvollziehbar.
tagged_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (file_id, tag_id)
);
-- Der Primärschlüssel deckt die Richtung Datei → Etiketten ab. Die andere
-- Richtung – „zeig mir alles mit steuer-2026" – ist genau die Abfrage, für die
-- Etiketten gebaut werden, und braucht ihren eigenen Index.
CREATE INDEX idx_file_tags_tag ON file_tags (tag_id);
-- ── Belegdatum ───────────────────────────────────────────────────────────────
-- Das Datum, das auf dem Dokument steht – nicht das des Uploads.
--
-- Für beide Zwecke entscheidend: Die Garantiefrist läuft ab dem **Kauf**, und in
-- die Steuererklärung 2026 gehört, was 2026 gekauft wurde – auch wenn der Beleg
-- erst im März 2027 eingescannt wird. created_at wäre für beides das falsche
-- Datum, und zwar unauffällig falsch.
--
-- NULL heißt schlicht „nicht gesetzt": Bei einem Urlaubsfoto gibt es kein
-- Belegdatum, und ein Vorgabewert wäre eine Behauptung.
ALTER TABLE files ADD COLUMN document_date DATE;
CREATE INDEX idx_files_document_date ON files (document_date) WHERE document_date IS NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE file_text DROP COLUMN IF EXISTS extract_version;
@@ -0,0 +1,13 @@
-- Seitengrenzen im erkannten Text.
--
-- Bis hierher ersetzte clean() den Seitenumbruch (\f) durch einen Zeilenumbruch:
-- Für die Volltextsuche ist er bedeutungslos, ein tsvector kennt keine Seiten.
-- Für den PDF-Betrachter ist er das Gegenteil von bedeutungslos – bei einem
-- Scan steht der Text *nur* hier, im PDF selbst liegt bloß ein Bild. Ohne die
-- Marke lässt sich nicht sagen, auf welcher Seite ein Suchwort steht, und der
-- Betrachter beginnt bei einem Beleg-Scan wieder stumpf auf Seite 1.
--
-- extract_version sagt, nach welcher Regel ein Eintrag entstanden ist. Ältere
-- Einträge werden beim Start erneut in die Warteschlange gelegt, statt still
-- ohne Seitenmarken liegen zu bleiben.
ALTER TABLE file_text ADD COLUMN IF NOT EXISTS extract_version smallint NOT NULL DEFAULT 0;
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_files_correspondent;
ALTER TABLE files DROP COLUMN IF EXISTS correspondent_id;
DROP TABLE IF EXISTS correspondents;
@@ -0,0 +1,52 @@
-- Absender („Korrespondent") an Dateien.
--
-- Warum neben Etiketten noch ein Feld: Es sind **zwei verschiedene Fragen**.
-- Ein Etikett sagt, *wofür* ein Dokument gebraucht wird („absetzbar",
-- „garantie") – davon trägt eine Datei beliebig viele. Der Absender sagt, *von
-- wem* es kommt („Stadtwerke", „Finanzamt") – davon hat eine Datei genau einen.
--
-- Als Etikett gelöst stünden beide in derselben Liste: „Stadtwerke" neben
-- „absetzbar", Absender und Verwendungszweck durcheinander. Bei zwanzig
-- Etiketten fällt das nicht auf, bei achtzig ist die Liste unbenutzbar. Und
-- „alle Rechnungen von X" ließe sich nicht von „alles Absetzbare" trennen –
-- genau die Verknüpfung, für die es gebaut wird.
--
-- Die Eins-Regel ist dabei kein Mangel, sondern der Zweck: Sie erzwingt eine
-- Entscheidung und hält die Liste kurz.
CREATE TABLE correspondents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Prinzipal-Modell wie bei tags: entweder ein Konto oder eine Gruppe.
-- In einem Gruppenordner müssen alle dieselben Absender sehen, sonst
-- sortiert jeder dieselben Dokumente für sich neu ein.
owner_id UUID REFERENCES users(id) ON DELETE CASCADE,
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT correspondents_one_principal CHECK (
(owner_id IS NOT NULL AND group_id IS NULL) OR
(owner_id IS NULL AND group_id IS NOT NULL)
),
CONSTRAINT correspondents_name_not_empty CHECK (length(btrim(name)) > 0)
);
-- Eindeutig je Prinzipal, ohne Rücksicht auf Groß-/Kleinschreibung – dieselbe
-- Regel wie bei Etiketten. Hier wiegt sie schwerer: „Amazon" und „amazon"
-- nebeneinander hieße, dass „alle Rechnungen von Amazon" nur die Hälfte
-- findet, und zwar unauffällig.
CREATE UNIQUE INDEX idx_correspondents_owner_name
ON correspondents (owner_id, lower(btrim(name))) WHERE owner_id IS NOT NULL;
CREATE UNIQUE INDEX idx_correspondents_group_name
ON correspondents (group_id, lower(btrim(name))) WHERE group_id IS NOT NULL;
-- Am Datei-Datensatz, nicht in einer Verknüpfungstabelle: Genau einer je
-- Datei – das steht damit im Schema und nicht bloß in einer Zusicherung des
-- Codes.
--
-- ON DELETE SET NULL statt CASCADE: Wer einen Absender löscht, will ihn aus
-- der Liste haben – nicht seine Rechnungen.
ALTER TABLE files ADD COLUMN correspondent_id UUID REFERENCES correspondents(id) ON DELETE SET NULL;
-- „Alles von Stadtwerke" ist die Abfrage, für die das Feld gebaut wird.
CREATE INDEX idx_files_correspondent ON files (correspondent_id) WHERE correspondent_id IS NOT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE tags DROP COLUMN IF EXISTS icon;
ALTER TABLE correspondents DROP COLUMN IF EXISTS icon;
@@ -0,0 +1,15 @@
-- Ein Symbol je Etikett und je Absender.
--
-- Die Farbe allein trägt nicht weit genug: Sie unterscheidet zuverlässig etwa
-- fünf Einträge. Danach fragt man sich, ob Blau nun „Uni" oder „Versicherung"
-- war. Ein Emoji ist sofort erkennbar, braucht keine Legende und ist in jeder
-- Schrift schon da – kein Symbolsatz, keine Grafikdateien, keine zusätzliche
-- Ladezeit.
--
-- Leer heißt „keins": Die Farbe bleibt der Rückfall, niemand muss ein Symbol
-- wählen. Ein Vorgabe-Emoji wäre eine Behauptung über den Inhalt.
--
-- TEXT statt eines einzelnen Zeichens: Ein Emoji ist selten ein Codepunkt.
-- 👨‍🎓 sind fünf, 🏳️‍🌈 vier – ein CHAR(1) hätte sie zerschnitten.
ALTER TABLE tags ADD COLUMN IF NOT EXISTS icon TEXT NOT NULL DEFAULT '';
ALTER TABLE correspondents ADD COLUMN IF NOT EXISTS icon TEXT NOT NULL DEFAULT '';