CREATE TABLE staff_time_v2_accounts ( tenant_id bigint NOT NULL REFERENCES tenants(id), user_id uuid NOT NULL REFERENCES auth_users(id), activated_at timestamptz NOT NULL DEFAULT now(), activated_by uuid REFERENCES auth_users(id), PRIMARY KEY(tenant_id,user_id) ); --> statement-breakpoint CREATE TABLE staff_time_spans ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id bigint NOT NULL REFERENCES tenants(id), user_id uuid NOT NULL REFERENCES auth_users(id), type text NOT NULL CHECK(type IN ('work','vacation','sick','overtime_compensation','holiday','other')), started_at timestamptz NOT NULL, ended_at timestamptz, date_from date, date_to date, timezone text NOT NULL DEFAULT 'Europe/Berlin', description text NOT NULL DEFAULT '', source text NOT NULL, status text NOT NULL DEFAULT 'factual' CHECK(status IN ('factual','submitted','approved','rejected')), version integer NOT NULL DEFAULT 1 CHECK(version > 0), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CHECK(ended_at IS NULL OR ended_at > started_at), CHECK((date_from IS NULL AND date_to IS NULL) OR (type <> 'work' AND date_from IS NOT NULL AND date_to IS NOT NULL AND date_to > date_from AND ended_at IS NOT NULL)), CHECK(status = 'factual' OR ended_at IS NOT NULL) ); CREATE INDEX staff_time_spans_user_time ON staff_time_spans(tenant_id,user_id,started_at); CREATE UNIQUE INDEX staff_time_spans_one_open ON staff_time_spans(tenant_id,user_id) WHERE ended_at IS NULL; --> statement-breakpoint CREATE TABLE staff_time_span_breaks ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), span_id uuid NOT NULL REFERENCES staff_time_spans(id), started_at timestamptz NOT NULL, ended_at timestamptz, CHECK(ended_at IS NULL OR ended_at > started_at) ); CREATE INDEX staff_time_span_breaks_parent ON staff_time_span_breaks(span_id); CREATE UNIQUE INDEX staff_time_span_breaks_one_open ON staff_time_span_breaks(span_id) WHERE ended_at IS NULL; CREATE TABLE staff_time_span_projects ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), span_id uuid NOT NULL REFERENCES staff_time_spans(id), project_id bigint NOT NULL REFERENCES projects(id), started_at timestamptz NOT NULL, ended_at timestamptz NOT NULL, notes text NOT NULL DEFAULT '', CHECK(ended_at > started_at) ); CREATE INDEX staff_time_span_projects_parent ON staff_time_span_projects(span_id); --> statement-breakpoint CREATE TABLE staff_time_span_history ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), span_id uuid NOT NULL REFERENCES staff_time_spans(id), actor_id uuid REFERENCES auth_users(id), action text NOT NULL, reason text NOT NULL DEFAULT '', before_data jsonb, after_data jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX staff_time_span_history_parent ON staff_time_span_history(span_id,created_at); CREATE TABLE staff_time_v2_requests ( tenant_id bigint NOT NULL, user_id uuid NOT NULL, request_id text NOT NULL, fingerprint text NOT NULL, response jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(tenant_id,user_id,request_id) ); CREATE TABLE staff_time_span_origins ( tenant_id bigint NOT NULL, source_kind text NOT NULL, source_id uuid NOT NULL, span_id uuid NOT NULL REFERENCES staff_time_spans(id), PRIMARY KEY(tenant_id,source_kind,source_id) ); --> statement-breakpoint -- The same lock as the service makes cutover atomic with concurrent legacy writers. CREATE FUNCTION staff_time_legacy_write_guard() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE t bigint; u uuid; BEGIN IF TG_OP = 'DELETE' THEN t := OLD.tenant_id; u := OLD.user_id; ELSE t := NEW.tenant_id; u := NEW.user_id; END IF; PERFORM pg_advisory_xact_lock(hashtextextended('staff-time:' || t || ':' || u,0)); IF EXISTS(SELECT 1 FROM staff_time_v2_accounts WHERE tenant_id=t AND user_id=u) THEN RAISE EXCEPTION 'Zeiterfassung 2.0 ist aktiv. Bitte die neue Erfassung verwenden.' USING ERRCODE='23514'; END IF; IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; RETURN NEW; END $$; CREATE TRIGGER staff_time_events_cutover BEFORE INSERT OR UPDATE OR DELETE ON staff_time_events FOR EACH ROW EXECUTE FUNCTION staff_time_legacy_write_guard(); CREATE TRIGGER staff_time_entries_cutover BEFORE INSERT OR UPDATE OR DELETE ON staff_time_entries FOR EACH ROW EXECUTE FUNCTION staff_time_legacy_write_guard(); --> statement-breakpoint CREATE FUNCTION staff_time_span_overlap_guard() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_advisory_xact_lock(hashtextextended('staff-time:' || NEW.tenant_id || ':' || NEW.user_id,0)); IF EXISTS(SELECT 1 FROM staff_time_spans s WHERE s.tenant_id=NEW.tenant_id AND s.user_id=NEW.user_id AND s.id<>NEW.id AND s.started_atNEW.started_at) THEN RAISE EXCEPTION 'Überlappende Zeitspannen' USING ERRCODE='23514'; END IF; RETURN NEW; END $$; CREATE TRIGGER staff_time_span_no_overlap BEFORE INSERT OR UPDATE ON staff_time_spans FOR EACH ROW EXECUTE FUNCTION staff_time_span_overlap_guard(); --> statement-breakpoint CREATE FUNCTION staff_time_legacy_project_guard() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE parent staff_time_entries; BEGIN IF TG_OP = 'DELETE' THEN SELECT * INTO parent FROM staff_time_entries WHERE id=OLD.time_entry_id; ELSE SELECT * INTO parent FROM staff_time_entries WHERE id=NEW.time_entry_id; END IF; PERFORM pg_advisory_xact_lock(hashtextextended('staff-time:' || parent.tenant_id || ':' || parent.user_id,0)); IF EXISTS(SELECT 1 FROM staff_time_v2_accounts WHERE tenant_id=parent.tenant_id AND user_id=parent.user_id) THEN RAISE EXCEPTION 'Projektzeiten unter 2.0 bearbeiten.' USING ERRCODE='23514'; END IF; IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; RETURN NEW; END $$; CREATE TRIGGER staff_time_project_cutover BEFORE INSERT OR UPDATE OR DELETE ON staff_time_entry_connects FOR EACH ROW EXECUTE FUNCTION staff_time_legacy_project_guard();