diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql deleted file mode 100644 index 92774d0..0000000 --- a/backend/migrations/001_init.sql +++ /dev/null @@ -1,425 +0,0 @@ --- ============================================================================= --- Tanabata File Manager — Database Schema v2 --- ============================================================================= --- PostgreSQL 14+ --- --- Design decisions: --- • Business logic lives in Go (DDD), no stored procedures --- • UUID v7 for entity PKs (created_at extracted from UUID, no separate column) --- • ACL: is_public flag on objects + acl.permissions table for granular control --- • Schemas: core, data, acl, activity --- • Flat pools (no hierarchy) --- • Soft delete for files only (trash/recycle bin) --- • phash field for future duplicate detection --- • metadata jsonb on all entities --- • Unified audit log with reference tables instead of enums --- ============================================================================= - --- --------------------------------------------------------------------------- --- Extensions --- --------------------------------------------------------------------------- - -CREATE EXTENSION IF NOT EXISTS pgcrypto; -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- --------------------------------------------------------------------------- --- Schemas --- --------------------------------------------------------------------------- - -CREATE SCHEMA IF NOT EXISTS core; -CREATE SCHEMA IF NOT EXISTS data; -CREATE SCHEMA IF NOT EXISTS acl; -CREATE SCHEMA IF NOT EXISTS activity; - --- --------------------------------------------------------------------------- --- Utility functions --- --------------------------------------------------------------------------- - --- UUID v7 generator -CREATE OR REPLACE FUNCTION public.uuid_v7(cts timestamptz DEFAULT clock_timestamp()) -RETURNS uuid LANGUAGE plpgsql AS $$ -DECLARE - state text = current_setting('uuidv7.old_tp', true); - old_tp text = split_part(state, ':', 1); - base int = coalesce(nullif(split_part(state, ':', 4), '')::int, (random()*16777215/2-1)::int); - tp text; - entropy text; - seq text = base; - seqn int = split_part(state, ':', 2); - ver text = coalesce(split_part(state, ':', 3), to_hex(8+(random()*3)::int)); -BEGIN - base = (random()*16777215/2-1)::int; - tp = lpad(to_hex(floor(extract(epoch from cts)*1000)::int8), 12, '0') || '7'; - IF tp IS DISTINCT FROM old_tp THEN - old_tp = tp; - ver = to_hex(8+(random()*3)::int); - base = (random()*16777215/2-1)::int; - seqn = base; - ELSE - seqn = seqn + (random()*1000)::int; - END IF; - PERFORM set_config('uuidv7.old_tp', old_tp||':'||seqn||':'||ver||':'||base, false); - entropy = md5(gen_random_uuid()::text); - seq = lpad(to_hex(seqn), 6, '0'); - RETURN (tp || substring(seq from 1 for 3) || ver || substring(seq from 4 for 3) || - substring(entropy from 1 for 12))::uuid; -END; -$$; - --- Extract timestamp from UUID v7 -CREATE OR REPLACE FUNCTION public.uuid_extract_timestamp(uuid_val uuid) -RETURNS timestamptz LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ - SELECT to_timestamp( - ('x' || left(replace(uuid_val::text, '-', ''), 12))::bit(48)::bigint / 1000.0 - ); -$$; - --- ============================================================================= --- SCHEMA: core --- ============================================================================= - --- Users -CREATE TABLE core.users ( - id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - name varchar(32) NOT NULL, - password text NOT NULL, -- bcrypt hash via pgcrypto - is_admin boolean NOT NULL DEFAULT false, - can_create boolean NOT NULL DEFAULT false, - - CONSTRAINT uni__users__name UNIQUE (name) -); - --- MIME types (whitelist of supported file types) -CREATE TABLE core.mime_types ( - id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - name varchar(127) NOT NULL, - extension varchar(16) NOT NULL, - - CONSTRAINT uni__mime_types__name UNIQUE (name) -); - --- Object types (file, tag, category, pool — used in ACL and audit log) -CREATE TABLE core.object_types ( - id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - name varchar(32) NOT NULL, - - CONSTRAINT uni__object_types__name UNIQUE (name) -); - --- ============================================================================= --- SCHEMA: data --- ============================================================================= - --- Categories (logical grouping of tags) -CREATE TABLE data.categories ( - id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY, - name varchar(256) NOT NULL, - notes text, - color char(6), - metadata jsonb, - creator_id smallint NOT NULL REFERENCES core.users(id) - ON UPDATE CASCADE ON DELETE RESTRICT, - is_public boolean NOT NULL DEFAULT false, - - CONSTRAINT uni__categories__name UNIQUE (name), - CONSTRAINT chk__categories__color_hex - CHECK (color IS NULL OR color ~* '^[A-Fa-f0-9]{6}$') -); - --- Tags -CREATE TABLE data.tags ( - id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY, - name varchar(256) NOT NULL, - notes text, - color char(6), - category_id uuid REFERENCES data.categories(id) - ON UPDATE CASCADE ON DELETE SET NULL, - metadata jsonb, - creator_id smallint NOT NULL REFERENCES core.users(id) - ON UPDATE CASCADE ON DELETE RESTRICT, - is_public boolean NOT NULL DEFAULT false, - - CONSTRAINT uni__tags__name UNIQUE (name), - CONSTRAINT chk__tags__color_hex - CHECK (color IS NULL OR color ~* '^[A-Fa-f0-9]{6}$') -); - --- Tag rules (when when_tag is added to a file, then_tag is also added) -CREATE TABLE data.tag_rules ( - when_tag_id uuid NOT NULL REFERENCES data.tags(id) - ON UPDATE CASCADE ON DELETE CASCADE, - then_tag_id uuid NOT NULL REFERENCES data.tags(id) - ON UPDATE CASCADE ON DELETE CASCADE, - is_active boolean NOT NULL DEFAULT true, - - PRIMARY KEY (when_tag_id, then_tag_id) -); - --- Files -CREATE TABLE data.files ( - id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY, - original_name varchar(256), -- original filename at upload time - mime_id smallint NOT NULL REFERENCES core.mime_types(id) - ON UPDATE CASCADE ON DELETE RESTRICT, - content_datetime timestamptz NOT NULL DEFAULT clock_timestamp(), -- content datetime (e.g. photo taken) - notes text, - metadata jsonb, -- user-editable key-value data - exif jsonb NOT NULL, -- EXIF data extracted at upload (immutable) - phash bigint, -- perceptual hash for duplicate detection (future) - creator_id smallint NOT NULL REFERENCES core.users(id) - ON UPDATE CASCADE ON DELETE RESTRICT, - is_public boolean NOT NULL DEFAULT false, - is_deleted boolean NOT NULL DEFAULT false -- soft delete (trash) -); - --- File ↔ Tag (many-to-many) -CREATE TABLE data.file_tag ( - file_id uuid NOT NULL REFERENCES data.files(id) - ON UPDATE CASCADE ON DELETE CASCADE, - tag_id uuid NOT NULL REFERENCES data.tags(id) - ON UPDATE CASCADE ON DELETE CASCADE, - - PRIMARY KEY (file_id, tag_id) -); - --- Pools (ordered collections of files) -CREATE TABLE data.pools ( - id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY, - name varchar(256) NOT NULL, - notes text, - metadata jsonb, - creator_id smallint NOT NULL REFERENCES core.users(id) - ON UPDATE CASCADE ON DELETE RESTRICT, - is_public boolean NOT NULL DEFAULT false, - - CONSTRAINT uni__pools__name UNIQUE (name) -); - --- File ↔ Pool (many-to-many, with ordering) --- `position` uses integer with gaps (e.g. 1000, 2000, 3000) to allow --- insertions without renumbering. Compact when gaps get too small. -CREATE TABLE data.file_pool ( - file_id uuid NOT NULL REFERENCES data.files(id) - ON UPDATE CASCADE ON DELETE CASCADE, - pool_id uuid NOT NULL REFERENCES data.pools(id) - ON UPDATE CASCADE ON DELETE CASCADE, - position integer NOT NULL DEFAULT 0, - - PRIMARY KEY (file_id, pool_id) -); - --- ============================================================================= --- SCHEMA: acl --- ============================================================================= - --- Granular permissions --- If is_public=true on the object, it is accessible to everyone (ACL ignored). --- If is_public=false, only creator and users with can_view=true see it. --- Admins bypass all ACL checks. -CREATE TABLE acl.permissions ( - user_id smallint NOT NULL REFERENCES core.users(id) - ON UPDATE CASCADE ON DELETE CASCADE, - object_type_id smallint NOT NULL REFERENCES core.object_types(id) - ON UPDATE CASCADE ON DELETE RESTRICT, - object_id uuid NOT NULL, - can_view boolean NOT NULL DEFAULT true, - can_edit boolean NOT NULL DEFAULT false, - - PRIMARY KEY (user_id, object_type_id, object_id) -); - --- ============================================================================= --- SCHEMA: activity --- ============================================================================= - --- Action types (reference table for audit log) -CREATE TABLE activity.action_types ( - id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - name varchar(64) NOT NULL, - - CONSTRAINT uni__action_types__name UNIQUE (name) -); - --- Sessions -CREATE TABLE activity.sessions ( - id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - token_hash text NOT NULL, -- hashed session token - user_id smallint NOT NULL REFERENCES core.users(id) - ON UPDATE CASCADE ON DELETE CASCADE, - user_agent varchar(256) NOT NULL, - started_at timestamptz NOT NULL DEFAULT statement_timestamp(), - expires_at timestamptz, - last_activity timestamptz NOT NULL DEFAULT statement_timestamp(), - - CONSTRAINT uni__sessions__token_hash UNIQUE (token_hash) -); - --- File views (analytics) -CREATE TABLE activity.file_views ( - file_id uuid NOT NULL REFERENCES data.files(id) - ON UPDATE CASCADE ON DELETE CASCADE, - user_id smallint NOT NULL REFERENCES core.users(id) - ON UPDATE CASCADE ON DELETE CASCADE, - viewed_at timestamptz NOT NULL DEFAULT statement_timestamp(), - - PRIMARY KEY (file_id, viewed_at, user_id) -); - --- Pool views (analytics) -CREATE TABLE activity.pool_views ( - pool_id uuid NOT NULL REFERENCES data.pools(id) - ON UPDATE CASCADE ON DELETE CASCADE, - user_id smallint NOT NULL REFERENCES core.users(id) - ON UPDATE CASCADE ON DELETE CASCADE, - viewed_at timestamptz NOT NULL DEFAULT statement_timestamp(), - - PRIMARY KEY (pool_id, viewed_at, user_id) -); - --- Tag usage tracking (when tag is used as filter) -CREATE TABLE activity.tag_uses ( - tag_id uuid NOT NULL REFERENCES data.tags(id) - ON UPDATE CASCADE ON DELETE CASCADE, - user_id smallint NOT NULL REFERENCES core.users(id) - ON UPDATE CASCADE ON DELETE CASCADE, - used_at timestamptz NOT NULL DEFAULT statement_timestamp(), - is_included boolean NOT NULL, -- true=included in filter, false=excluded - - PRIMARY KEY (tag_id, used_at, user_id) -); - --- Audit log (unified journal for all user actions) -CREATE TABLE activity.audit_log ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - user_id smallint NOT NULL REFERENCES core.users(id) - ON UPDATE CASCADE ON DELETE RESTRICT, - action_type_id smallint NOT NULL REFERENCES activity.action_types(id) - ON UPDATE CASCADE ON DELETE RESTRICT, - object_type_id smallint REFERENCES core.object_types(id) - ON UPDATE CASCADE ON DELETE RESTRICT, - object_id uuid, - details jsonb, -- action-specific payload - performed_at timestamptz NOT NULL DEFAULT statement_timestamp() -); - --- ============================================================================= --- SEED DATA --- ============================================================================= - --- Object types -INSERT INTO core.object_types (name) VALUES - ('file'), ('tag'), ('category'), ('pool'); - --- Action types -INSERT INTO activity.action_types (name) VALUES - -- Auth - ('user_login'), ('user_logout'), - -- Files - ('file_create'), ('file_edit'), ('file_delete'), ('file_restore'), - ('file_permanent_delete'), ('file_replace'), - -- Tags - ('tag_create'), ('tag_edit'), ('tag_delete'), - -- Categories - ('category_create'), ('category_edit'), ('category_delete'), - -- Pools - ('pool_create'), ('pool_edit'), ('pool_delete'), - -- Relations - ('file_tag_add'), ('file_tag_remove'), - ('file_pool_add'), ('file_pool_remove'), - -- ACL - ('acl_change'), - -- Admin - ('user_create'), ('user_delete'), ('user_block'), ('user_unblock'), - ('user_role_change'), - -- Sessions - ('session_terminate'); - --- ============================================================================= --- INDEXES --- ============================================================================= - --- core -CREATE INDEX idx__users__name ON core.users USING hash (name); - --- data.categories -CREATE INDEX idx__categories__creator_id ON data.categories USING hash (creator_id); - --- data.tags -CREATE INDEX idx__tags__category_id ON data.tags USING hash (category_id); -CREATE INDEX idx__tags__creator_id ON data.tags USING hash (creator_id); - --- data.tag_rules -CREATE INDEX idx__tag_rules__when ON data.tag_rules USING hash (when_tag_id); -CREATE INDEX idx__tag_rules__then ON data.tag_rules USING hash (then_tag_id); - --- data.files -CREATE INDEX idx__files__mime_id ON data.files USING hash (mime_id); -CREATE INDEX idx__files__creator_id ON data.files USING hash (creator_id); -CREATE INDEX idx__files__content_datetime ON data.files USING btree (content_datetime DESC NULLS LAST); -CREATE INDEX idx__files__is_deleted ON data.files USING btree (is_deleted) WHERE is_deleted = true; -CREATE INDEX idx__files__phash ON data.files USING btree (phash) WHERE phash IS NOT NULL; - --- data.file_tag -CREATE INDEX idx__file_tag__tag_id ON data.file_tag USING hash (tag_id); -CREATE INDEX idx__file_tag__file_id ON data.file_tag USING hash (file_id); - --- data.pools -CREATE INDEX idx__pools__creator_id ON data.pools USING hash (creator_id); - --- data.file_pool -CREATE INDEX idx__file_pool__pool_id ON data.file_pool USING hash (pool_id); -CREATE INDEX idx__file_pool__file_id ON data.file_pool USING hash (file_id); - --- acl.permissions -CREATE INDEX idx__acl__object ON acl.permissions USING btree (object_type_id, object_id); -CREATE INDEX idx__acl__user ON acl.permissions USING hash (user_id); - --- activity.sessions -CREATE INDEX idx__sessions__user_id ON activity.sessions USING hash (user_id); -CREATE INDEX idx__sessions__token_hash ON activity.sessions USING hash (token_hash); - --- activity.file_views -CREATE INDEX idx__file_views__user_id ON activity.file_views USING hash (user_id); - --- activity.pool_views -CREATE INDEX idx__pool_views__user_id ON activity.pool_views USING hash (user_id); - --- activity.tag_uses -CREATE INDEX idx__tag_uses__user_id ON activity.tag_uses USING hash (user_id); - --- activity.audit_log -CREATE INDEX idx__audit_log__user_id ON activity.audit_log USING hash (user_id); -CREATE INDEX idx__audit_log__action_type_id ON activity.audit_log USING hash (action_type_id); -CREATE INDEX idx__audit_log__object ON activity.audit_log USING btree (object_type_id, object_id) - WHERE object_id IS NOT NULL; -CREATE INDEX idx__audit_log__performed_at ON activity.audit_log USING btree (performed_at DESC); - --- ============================================================================= --- COMMENTS --- ============================================================================= - -COMMENT ON TABLE core.users IS 'Application users'; -COMMENT ON TABLE core.mime_types IS 'Whitelist of supported MIME types'; -COMMENT ON TABLE core.object_types IS 'Reference: entity types for ACL and audit log'; -COMMENT ON TABLE data.categories IS 'Logical grouping of tags'; -COMMENT ON TABLE data.tags IS 'File labels/tags'; -COMMENT ON TABLE data.tag_rules IS 'Auto-tagging rules: when when_tag is assigned, then_tag follows'; -COMMENT ON TABLE data.files IS 'Managed files; actual content stored on disk as {id}.{ext}'; -COMMENT ON TABLE data.file_tag IS 'Many-to-many: files <-> tags'; -COMMENT ON TABLE data.pools IS 'Ordered collections of files'; -COMMENT ON TABLE data.file_pool IS 'Many-to-many: files <-> pools, with ordering'; -COMMENT ON TABLE acl.permissions IS 'Per-object permissions (used when is_public=false)'; -COMMENT ON TABLE activity.action_types IS 'Reference: types of auditable user actions'; -COMMENT ON TABLE activity.sessions IS 'Active user sessions'; -COMMENT ON TABLE activity.file_views IS 'File view history'; -COMMENT ON TABLE activity.pool_views IS 'Pool view history'; -COMMENT ON TABLE activity.tag_uses IS 'Tag usage in filters'; -COMMENT ON TABLE activity.audit_log IS 'Unified audit trail for all user actions'; - -COMMENT ON COLUMN data.files.original_name IS 'Original filename at upload time'; -COMMENT ON COLUMN data.files.content_datetime IS 'Content datetime (e.g. when photo was taken); falls back to EXIF DateTimeOriginal'; -COMMENT ON COLUMN data.files.metadata IS 'User-editable key-value metadata'; -COMMENT ON COLUMN data.files.exif IS 'EXIF data extracted at upload time (immutable, system-managed)'; -COMMENT ON COLUMN data.files.phash IS 'Perceptual hash for image/video duplicate detection'; -COMMENT ON COLUMN data.files.is_deleted IS 'Soft-deleted files (trash); true = in recycle bin'; -COMMENT ON COLUMN data.file_pool.position IS 'Manual ordering within pool; uses gapped integers'; diff --git a/backend/migrations/001_init_schemas.sql b/backend/migrations/001_init_schemas.sql new file mode 100644 index 0000000..1309264 --- /dev/null +++ b/backend/migrations/001_init_schemas.sql @@ -0,0 +1,61 @@ +-- +goose Up + +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE SCHEMA IF NOT EXISTS core; +CREATE SCHEMA IF NOT EXISTS data; +CREATE SCHEMA IF NOT EXISTS acl; +CREATE SCHEMA IF NOT EXISTS activity; + +-- UUID v7 generator +CREATE OR REPLACE FUNCTION public.uuid_v7(cts timestamptz DEFAULT clock_timestamp()) +RETURNS uuid LANGUAGE plpgsql AS $$ +DECLARE + state text = current_setting('uuidv7.old_tp', true); + old_tp text = split_part(state, ':', 1); + base int = coalesce(nullif(split_part(state, ':', 4), '')::int, (random()*16777215/2-1)::int); + tp text; + entropy text; + seq text = base; + seqn int = split_part(state, ':', 2); + ver text = coalesce(split_part(state, ':', 3), to_hex(8+(random()*3)::int)); +BEGIN + base = (random()*16777215/2-1)::int; + tp = lpad(to_hex(floor(extract(epoch from cts)*1000)::int8), 12, '0') || '7'; + IF tp IS DISTINCT FROM old_tp THEN + old_tp = tp; + ver = to_hex(8+(random()*3)::int); + base = (random()*16777215/2-1)::int; + seqn = base; + ELSE + seqn = seqn + (random()*1000)::int; + END IF; + PERFORM set_config('uuidv7.old_tp', old_tp||':'||seqn||':'||ver||':'||base, false); + entropy = md5(gen_random_uuid()::text); + seq = lpad(to_hex(seqn), 6, '0'); + RETURN (tp || substring(seq from 1 for 3) || ver || substring(seq from 4 for 3) || + substring(entropy from 1 for 12))::uuid; +END; +$$; + +-- Extract timestamp from UUID v7 +CREATE OR REPLACE FUNCTION public.uuid_extract_timestamp(uuid_val uuid) +RETURNS timestamptz LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ + SELECT to_timestamp( + ('x' || left(replace(uuid_val::text, '-', ''), 12))::bit(48)::bigint / 1000.0 + ); +$$; + +-- +goose Down + +DROP FUNCTION IF EXISTS public.uuid_extract_timestamp(uuid); +DROP FUNCTION IF EXISTS public.uuid_v7(timestamptz); + +DROP SCHEMA IF EXISTS activity; +DROP SCHEMA IF EXISTS acl; +DROP SCHEMA IF EXISTS data; +DROP SCHEMA IF EXISTS core; + +DROP EXTENSION IF EXISTS "uuid-ossp"; +DROP EXTENSION IF EXISTS pgcrypto; diff --git a/backend/migrations/002_core_tables.sql b/backend/migrations/002_core_tables.sql new file mode 100644 index 0000000..602e6ed --- /dev/null +++ b/backend/migrations/002_core_tables.sql @@ -0,0 +1,36 @@ +-- +goose Up + +CREATE TABLE core.users ( + id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name varchar(32) NOT NULL, + password text NOT NULL, -- bcrypt hash via pgcrypto + is_admin boolean NOT NULL DEFAULT false, + can_create boolean NOT NULL DEFAULT false, + + CONSTRAINT uni__users__name UNIQUE (name) +); + +CREATE TABLE core.mime_types ( + id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name varchar(127) NOT NULL, + extension varchar(16) NOT NULL, + + CONSTRAINT uni__mime_types__name UNIQUE (name) +); + +CREATE TABLE core.object_types ( + id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name varchar(32) NOT NULL, + + CONSTRAINT uni__object_types__name UNIQUE (name) +); + +COMMENT ON TABLE core.users IS 'Application users'; +COMMENT ON TABLE core.mime_types IS 'Whitelist of supported MIME types'; +COMMENT ON TABLE core.object_types IS 'Reference: entity types for ACL and audit log'; + +-- +goose Down + +DROP TABLE IF EXISTS core.object_types; +DROP TABLE IF EXISTS core.mime_types; +DROP TABLE IF EXISTS core.users; diff --git a/backend/migrations/003_data_tables.sql b/backend/migrations/003_data_tables.sql new file mode 100644 index 0000000..29c71d5 --- /dev/null +++ b/backend/migrations/003_data_tables.sql @@ -0,0 +1,118 @@ +-- +goose Up + +CREATE TABLE data.categories ( + id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY, + name varchar(256) NOT NULL, + notes text, + color char(6), + metadata jsonb, + creator_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE RESTRICT, + is_public boolean NOT NULL DEFAULT false, + + CONSTRAINT uni__categories__name UNIQUE (name), + CONSTRAINT chk__categories__color_hex + CHECK (color IS NULL OR color ~* '^[A-Fa-f0-9]{6}$') +); + +CREATE TABLE data.tags ( + id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY, + name varchar(256) NOT NULL, + notes text, + color char(6), + category_id uuid REFERENCES data.categories(id) + ON UPDATE CASCADE ON DELETE SET NULL, + metadata jsonb, + creator_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE RESTRICT, + is_public boolean NOT NULL DEFAULT false, + + CONSTRAINT uni__tags__name UNIQUE (name), + CONSTRAINT chk__tags__color_hex + CHECK (color IS NULL OR color ~* '^[A-Fa-f0-9]{6}$') +); + +CREATE TABLE data.tag_rules ( + when_tag_id uuid NOT NULL REFERENCES data.tags(id) + ON UPDATE CASCADE ON DELETE CASCADE, + then_tag_id uuid NOT NULL REFERENCES data.tags(id) + ON UPDATE CASCADE ON DELETE CASCADE, + is_active boolean NOT NULL DEFAULT true, + + PRIMARY KEY (when_tag_id, then_tag_id) +); + +CREATE TABLE data.files ( + id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY, + original_name varchar(256), -- original filename at upload time + mime_id smallint NOT NULL REFERENCES core.mime_types(id) + ON UPDATE CASCADE ON DELETE RESTRICT, + content_datetime timestamptz NOT NULL DEFAULT clock_timestamp(), -- content datetime (e.g. photo taken) + notes text, + metadata jsonb, -- user-editable key-value data + exif jsonb NOT NULL DEFAULT '{}'::jsonb, -- EXIF data extracted at upload (immutable) + phash bigint, -- perceptual hash for duplicate detection (future) + creator_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE RESTRICT, + is_public boolean NOT NULL DEFAULT false, + is_deleted boolean NOT NULL DEFAULT false -- soft delete (trash) +); + +CREATE TABLE data.file_tag ( + file_id uuid NOT NULL REFERENCES data.files(id) + ON UPDATE CASCADE ON DELETE CASCADE, + tag_id uuid NOT NULL REFERENCES data.tags(id) + ON UPDATE CASCADE ON DELETE CASCADE, + + PRIMARY KEY (file_id, tag_id) +); + +CREATE TABLE data.pools ( + id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY, + name varchar(256) NOT NULL, + notes text, + metadata jsonb, + creator_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE RESTRICT, + is_public boolean NOT NULL DEFAULT false, + + CONSTRAINT uni__pools__name UNIQUE (name) +); + +-- `position` uses integer with gaps (e.g. 1000, 2000, 3000) to allow +-- insertions without renumbering. Compact when gaps get too small. +CREATE TABLE data.file_pool ( + file_id uuid NOT NULL REFERENCES data.files(id) + ON UPDATE CASCADE ON DELETE CASCADE, + pool_id uuid NOT NULL REFERENCES data.pools(id) + ON UPDATE CASCADE ON DELETE CASCADE, + position integer NOT NULL DEFAULT 0, + + PRIMARY KEY (file_id, pool_id) +); + +COMMENT ON TABLE data.categories IS 'Logical grouping of tags'; +COMMENT ON TABLE data.tags IS 'File labels/tags'; +COMMENT ON TABLE data.tag_rules IS 'Auto-tagging rules: when when_tag is assigned, then_tag follows'; +COMMENT ON TABLE data.files IS 'Managed files; actual content stored on disk as {id}.{ext}'; +COMMENT ON TABLE data.file_tag IS 'Many-to-many: files <-> tags'; +COMMENT ON TABLE data.pools IS 'Ordered collections of files'; +COMMENT ON TABLE data.file_pool IS 'Many-to-many: files <-> pools, with ordering'; + +COMMENT ON COLUMN data.files.original_name IS 'Original filename at upload time'; +COMMENT ON COLUMN data.files.content_datetime IS 'Content datetime (e.g. when photo was taken); falls back to EXIF DateTimeOriginal'; +COMMENT ON COLUMN data.files.metadata IS 'User-editable key-value metadata'; +COMMENT ON COLUMN data.files.exif IS 'EXIF data extracted at upload time (immutable, system-managed)'; +COMMENT ON COLUMN data.files.phash IS 'Perceptual hash for image/video duplicate detection'; +COMMENT ON COLUMN data.files.is_deleted IS 'Soft-deleted files (trash); true = in recycle bin'; +COMMENT ON COLUMN data.file_pool.position IS 'Manual ordering within pool; uses gapped integers'; + +-- +goose Down + +DROP TABLE IF EXISTS data.file_pool; +DROP TABLE IF EXISTS data.pools; +DROP TABLE IF EXISTS data.file_tag; +DROP TABLE IF EXISTS data.files; +DROP TABLE IF EXISTS data.tag_rules; +DROP TABLE IF EXISTS data.tags; +DROP TABLE IF EXISTS data.categories; diff --git a/backend/migrations/004_acl_tables.sql b/backend/migrations/004_acl_tables.sql new file mode 100644 index 0000000..e017553 --- /dev/null +++ b/backend/migrations/004_acl_tables.sql @@ -0,0 +1,22 @@ +-- +goose Up + +-- If is_public=true on the object, it is accessible to everyone (ACL ignored). +-- If is_public=false, only creator and users with can_view=true see it. +-- Admins bypass all ACL checks. +CREATE TABLE acl.permissions ( + user_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE CASCADE, + object_type_id smallint NOT NULL REFERENCES core.object_types(id) + ON UPDATE CASCADE ON DELETE RESTRICT, + object_id uuid NOT NULL, + can_view boolean NOT NULL DEFAULT true, + can_edit boolean NOT NULL DEFAULT false, + + PRIMARY KEY (user_id, object_type_id, object_id) +); + +COMMENT ON TABLE acl.permissions IS 'Per-object permissions (used when is_public=false)'; + +-- +goose Down + +DROP TABLE IF EXISTS acl.permissions; diff --git a/backend/migrations/005_activity_tables.sql b/backend/migrations/005_activity_tables.sql new file mode 100644 index 0000000..e457271 --- /dev/null +++ b/backend/migrations/005_activity_tables.sql @@ -0,0 +1,81 @@ +-- +goose Up + +CREATE TABLE activity.action_types ( + id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name varchar(64) NOT NULL, + + CONSTRAINT uni__action_types__name UNIQUE (name) +); + +CREATE TABLE activity.sessions ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + token_hash text NOT NULL, -- hashed session token + user_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE CASCADE, + user_agent varchar(256) NOT NULL, + started_at timestamptz NOT NULL DEFAULT statement_timestamp(), + expires_at timestamptz, + last_activity timestamptz NOT NULL DEFAULT statement_timestamp(), + + CONSTRAINT uni__sessions__token_hash UNIQUE (token_hash) +); + +CREATE TABLE activity.file_views ( + file_id uuid NOT NULL REFERENCES data.files(id) + ON UPDATE CASCADE ON DELETE CASCADE, + user_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE CASCADE, + viewed_at timestamptz NOT NULL DEFAULT statement_timestamp(), + + PRIMARY KEY (file_id, viewed_at, user_id) +); + +CREATE TABLE activity.pool_views ( + pool_id uuid NOT NULL REFERENCES data.pools(id) + ON UPDATE CASCADE ON DELETE CASCADE, + user_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE CASCADE, + viewed_at timestamptz NOT NULL DEFAULT statement_timestamp(), + + PRIMARY KEY (pool_id, viewed_at, user_id) +); + +CREATE TABLE activity.tag_uses ( + tag_id uuid NOT NULL REFERENCES data.tags(id) + ON UPDATE CASCADE ON DELETE CASCADE, + user_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE CASCADE, + used_at timestamptz NOT NULL DEFAULT statement_timestamp(), + is_included boolean NOT NULL, -- true=included in filter, false=excluded + + PRIMARY KEY (tag_id, used_at, user_id) +); + +CREATE TABLE activity.audit_log ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE RESTRICT, + action_type_id smallint NOT NULL REFERENCES activity.action_types(id) + ON UPDATE CASCADE ON DELETE RESTRICT, + object_type_id smallint REFERENCES core.object_types(id) + ON UPDATE CASCADE ON DELETE RESTRICT, + object_id uuid, + details jsonb, -- action-specific payload + performed_at timestamptz NOT NULL DEFAULT statement_timestamp() +); + +COMMENT ON TABLE activity.action_types IS 'Reference: types of auditable user actions'; +COMMENT ON TABLE activity.sessions IS 'Active user sessions'; +COMMENT ON TABLE activity.file_views IS 'File view history'; +COMMENT ON TABLE activity.pool_views IS 'Pool view history'; +COMMENT ON TABLE activity.tag_uses IS 'Tag usage in filters'; +COMMENT ON TABLE activity.audit_log IS 'Unified audit trail for all user actions'; + +-- +goose Down + +DROP TABLE IF EXISTS activity.audit_log; +DROP TABLE IF EXISTS activity.tag_uses; +DROP TABLE IF EXISTS activity.pool_views; +DROP TABLE IF EXISTS activity.file_views; +DROP TABLE IF EXISTS activity.sessions; +DROP TABLE IF EXISTS activity.action_types; diff --git a/backend/migrations/006_indexes.sql b/backend/migrations/006_indexes.sql new file mode 100644 index 0000000..cd9c81e --- /dev/null +++ b/backend/migrations/006_indexes.sql @@ -0,0 +1,87 @@ +-- +goose Up + +-- core +CREATE INDEX idx__users__name ON core.users USING hash (name); + +-- data.categories +CREATE INDEX idx__categories__creator_id ON data.categories USING hash (creator_id); + +-- data.tags +CREATE INDEX idx__tags__category_id ON data.tags USING hash (category_id); +CREATE INDEX idx__tags__creator_id ON data.tags USING hash (creator_id); + +-- data.tag_rules +CREATE INDEX idx__tag_rules__when ON data.tag_rules USING hash (when_tag_id); +CREATE INDEX idx__tag_rules__then ON data.tag_rules USING hash (then_tag_id); + +-- data.files +CREATE INDEX idx__files__mime_id ON data.files USING hash (mime_id); +CREATE INDEX idx__files__creator_id ON data.files USING hash (creator_id); +CREATE INDEX idx__files__content_datetime ON data.files USING btree (content_datetime DESC NULLS LAST); +CREATE INDEX idx__files__is_deleted ON data.files USING btree (is_deleted) WHERE is_deleted = true; +CREATE INDEX idx__files__phash ON data.files USING btree (phash) WHERE phash IS NOT NULL; + +-- data.file_tag +CREATE INDEX idx__file_tag__tag_id ON data.file_tag USING hash (tag_id); +CREATE INDEX idx__file_tag__file_id ON data.file_tag USING hash (file_id); + +-- data.pools +CREATE INDEX idx__pools__creator_id ON data.pools USING hash (creator_id); + +-- data.file_pool +CREATE INDEX idx__file_pool__pool_id ON data.file_pool USING hash (pool_id); +CREATE INDEX idx__file_pool__file_id ON data.file_pool USING hash (file_id); + +-- acl.permissions +CREATE INDEX idx__acl__object ON acl.permissions USING btree (object_type_id, object_id); +CREATE INDEX idx__acl__user ON acl.permissions USING hash (user_id); + +-- activity.sessions +CREATE INDEX idx__sessions__user_id ON activity.sessions USING hash (user_id); +CREATE INDEX idx__sessions__token_hash ON activity.sessions USING hash (token_hash); + +-- activity.file_views +CREATE INDEX idx__file_views__user_id ON activity.file_views USING hash (user_id); + +-- activity.pool_views +CREATE INDEX idx__pool_views__user_id ON activity.pool_views USING hash (user_id); + +-- activity.tag_uses +CREATE INDEX idx__tag_uses__user_id ON activity.tag_uses USING hash (user_id); + +-- activity.audit_log +CREATE INDEX idx__audit_log__user_id ON activity.audit_log USING hash (user_id); +CREATE INDEX idx__audit_log__action_type_id ON activity.audit_log USING hash (action_type_id); +CREATE INDEX idx__audit_log__object ON activity.audit_log USING btree (object_type_id, object_id) + WHERE object_id IS NOT NULL; +CREATE INDEX idx__audit_log__performed_at ON activity.audit_log USING btree (performed_at DESC); + +-- +goose Down + +DROP INDEX IF EXISTS activity.idx__audit_log__performed_at; +DROP INDEX IF EXISTS activity.idx__audit_log__object; +DROP INDEX IF EXISTS activity.idx__audit_log__action_type_id; +DROP INDEX IF EXISTS activity.idx__audit_log__user_id; +DROP INDEX IF EXISTS activity.idx__tag_uses__user_id; +DROP INDEX IF EXISTS activity.idx__pool_views__user_id; +DROP INDEX IF EXISTS activity.idx__file_views__user_id; +DROP INDEX IF EXISTS activity.idx__sessions__token_hash; +DROP INDEX IF EXISTS activity.idx__sessions__user_id; +DROP INDEX IF EXISTS acl.idx__acl__user; +DROP INDEX IF EXISTS acl.idx__acl__object; +DROP INDEX IF EXISTS data.idx__file_pool__file_id; +DROP INDEX IF EXISTS data.idx__file_pool__pool_id; +DROP INDEX IF EXISTS data.idx__pools__creator_id; +DROP INDEX IF EXISTS data.idx__file_tag__file_id; +DROP INDEX IF EXISTS data.idx__file_tag__tag_id; +DROP INDEX IF EXISTS data.idx__files__phash; +DROP INDEX IF EXISTS data.idx__files__is_deleted; +DROP INDEX IF EXISTS data.idx__files__content_datetime; +DROP INDEX IF EXISTS data.idx__files__creator_id; +DROP INDEX IF EXISTS data.idx__files__mime_id; +DROP INDEX IF EXISTS data.idx__tag_rules__then; +DROP INDEX IF EXISTS data.idx__tag_rules__when; +DROP INDEX IF EXISTS data.idx__tags__creator_id; +DROP INDEX IF EXISTS data.idx__tags__category_id; +DROP INDEX IF EXISTS data.idx__categories__creator_id; +DROP INDEX IF EXISTS core.idx__users__name; diff --git a/backend/migrations/007_seed_data.sql b/backend/migrations/007_seed_data.sql new file mode 100644 index 0000000..c8bd4dc --- /dev/null +++ b/backend/migrations/007_seed_data.sql @@ -0,0 +1,32 @@ +-- +goose Up + +INSERT INTO core.object_types (name) VALUES + ('file'), ('tag'), ('category'), ('pool'); + +INSERT INTO activity.action_types (name) VALUES + -- Auth + ('user_login'), ('user_logout'), + -- Files + ('file_create'), ('file_edit'), ('file_delete'), ('file_restore'), + ('file_permanent_delete'), ('file_replace'), + -- Tags + ('tag_create'), ('tag_edit'), ('tag_delete'), + -- Categories + ('category_create'), ('category_edit'), ('category_delete'), + -- Pools + ('pool_create'), ('pool_edit'), ('pool_delete'), + -- Relations + ('file_tag_add'), ('file_tag_remove'), + ('file_pool_add'), ('file_pool_remove'), + -- ACL + ('acl_change'), + -- Admin + ('user_create'), ('user_delete'), ('user_block'), ('user_unblock'), + ('user_role_change'), + -- Sessions + ('session_terminate'); + +-- +goose Down + +DELETE FROM activity.action_types; +DELETE FROM core.object_types;