-- Folder trees and personal files are metadata over the existing payload keys. CREATE TABLE file_folders ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, group_id uuid NULL REFERENCES groups(id) ON DELETE CASCADE, owner_id uuid NULL REFERENCES users(id) ON DELETE CASCADE, parent_id uuid NULL REFERENCES file_folders(id) ON DELETE CASCADE, name text NOT NULL CHECK (btrim(name) <> '' AND char_length(name) <= 255), created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT file_folders_scope CHECK ((group_id IS NULL) <> (owner_id IS NULL)), UNIQUE (id, workspace_id), FOREIGN KEY (group_id, workspace_id) REFERENCES groups(id, workspace_id) ON DELETE CASCADE ); CREATE TRIGGER trg_file_folders_ws_immutable BEFORE UPDATE ON file_folders FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); ALTER TABLE file_folders ENABLE ROW LEVEL SECURITY; ALTER TABLE file_folders FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON file_folders USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); GRANT SELECT, INSERT, UPDATE, DELETE ON file_folders TO sp_app; ALTER TABLE chat_media_archive ALTER COLUMN group_id DROP NOT NULL, ADD COLUMN folder_id uuid NULL REFERENCES file_folders(id) ON DELETE SET NULL, ADD COLUMN created_by uuid NULL, -- Required even at the personal root, which has no folder from which to infer ownership. ADD COLUMN owner_id uuid NULL REFERENCES users(id) ON DELETE CASCADE, ADD CONSTRAINT chat_media_archive_scope CHECK ((group_id IS NULL) <> (owner_id IS NULL)); -- 045 may already have introduced this setting. ALTER TABLE groups ADD COLUMN IF NOT EXISTS member_delete_own boolean NOT NULL DEFAULT true; -- Only exact, known identities are mapped. New authenticated writes supply -- created_by; Matrix ingest also resolves the configured homeserver in code. UPDATE chat_media_archive a SET created_by = ( SELECT u.id FROM users u WHERE (a.sender_mxid = '@' || u.username || ':tmbh.local' OR a.sender_mxid = u.external_id) AND EXISTS (SELECT 1 FROM memberships m WHERE m.workspace_id = a.workspace_id AND m.user_id = u.id) ORDER BY (a.sender_mxid = u.external_id) DESC, u.id LIMIT 1 ) WHERE a.created_by IS NULL; -- Other ingest clients (including archive-bot) write this table directly. -- Keep exact known sender mappings attributed on those inserts too. Unknown -- remote identities stay NULL, so they never acquire member delete rights. CREATE FUNCTION archive_file_creator() RETURNS trigger LANGUAGE plpgsql SET search_path = public AS $$ BEGIN IF NEW.created_by IS NULL THEN SELECT u.id INTO NEW.created_by FROM users u WHERE (NEW.sender_mxid = '@' || u.username || ':tmbh.local' OR NEW.sender_mxid = u.external_id) AND EXISTS (SELECT 1 FROM memberships m WHERE m.workspace_id = NEW.workspace_id AND m.user_id = u.id) ORDER BY (NEW.sender_mxid = u.external_id) DESC, u.id LIMIT 1; END IF; RETURN NEW; END; $$; CREATE TRIGGER trg_chat_media_archive_creator BEFORE INSERT ON chat_media_archive FOR EACH ROW EXECUTE FUNCTION archive_file_creator(); CREATE INDEX file_folders_scope_parent_idx ON file_folders (workspace_id, group_id, owner_id, parent_id); CREATE INDEX chat_media_archive_folder_idx ON chat_media_archive (workspace_id, folder_id); CREATE INDEX chat_media_archive_personal_idx ON chat_media_archive (workspace_id, owner_id, created_at DESC, id DESC) WHERE group_id IS NULL;