TeamVis Self-Host-Bundle v0.31.0
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
-- =====================================================================
|
||||
-- 0000 — Basis-Schema (employees + admin_users)
|
||||
-- =====================================================================
|
||||
-- Die ursprünglichen Kern-Tabellen wurden früher direkt in Supabase Studio
|
||||
-- angelegt und waren NICHT als Migration erfasst — die folgenden
|
||||
-- Migrationen (0001 ff.) setzen sie voraus und erweitern sie nur additiv
|
||||
-- (alle per `add column if not exists`). Auf einer komplett leeren DB fehlten
|
||||
-- sie dadurch, womit `bundle-migrations` allein keine frische Instanz
|
||||
-- bootstrappen konnte.
|
||||
--
|
||||
-- Diese Migration schließt die Lücke. Sie ist bewusst `if not exists`:
|
||||
-- auf bestehenden Instanzen (Prod/Demo) ein No-Op, auf leeren DBs legt sie
|
||||
-- die Basis. Spaltenstand entspricht dem aktuellen, generierten Typ
|
||||
-- (lib/database.types.ts) — alle späteren Migrationen sind additiv und
|
||||
-- bleiben damit No-Ops auf diesen Spalten.
|
||||
|
||||
create table if not exists public.employees (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
slug text not null unique,
|
||||
academic_title text,
|
||||
qualification text,
|
||||
qualification_en text,
|
||||
first_name text not null,
|
||||
last_name text not null,
|
||||
position text not null,
|
||||
position_en text,
|
||||
company text not null,
|
||||
org_unit_id uuid,
|
||||
address text,
|
||||
street text,
|
||||
postal_code text,
|
||||
city text,
|
||||
email text not null,
|
||||
phone_office text not null,
|
||||
phone_mobile text,
|
||||
website text,
|
||||
linkedin_url text,
|
||||
xing_url text,
|
||||
calendar_url text,
|
||||
photo_url text,
|
||||
show_mobile boolean not null default false,
|
||||
show_linkedin boolean not null default false,
|
||||
show_xing boolean not null default false,
|
||||
active boolean not null default true,
|
||||
calendar_embed boolean not null default false,
|
||||
successor_employee_id uuid,
|
||||
successor_note text,
|
||||
location_id uuid,
|
||||
person_id uuid,
|
||||
trusted_token text,
|
||||
trusted_token_expires_at timestamptz,
|
||||
trusted_token_created_at timestamptz,
|
||||
trusted_reveal text[] not null default '{}',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.admin_users (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
email text not null unique,
|
||||
password_hash text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
name text,
|
||||
role text not null default 'admin',
|
||||
invited_by uuid,
|
||||
last_login_at timestamptz,
|
||||
totp_secret text,
|
||||
totp_enabled boolean not null default false,
|
||||
totp_backup_codes text[] not null default '{}',
|
||||
totp_enabled_at timestamptz
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Row-Level-Security für öffentliche Reads auf employees
|
||||
-- Die App benutzt den anon-Key für öffentliche /[slug]- und /api/vcard-Routes.
|
||||
-- Der service_role-Key (Admin-Pfade) umgeht RLS automatisch.
|
||||
|
||||
alter table public.employees enable row level security;
|
||||
|
||||
drop policy if exists "public_read_active_employees" on public.employees;
|
||||
create policy "public_read_active_employees"
|
||||
on public.employees
|
||||
for select
|
||||
to anon
|
||||
using (active = true);
|
||||
|
||||
-- admin_users darf von anon gar nicht gelesen werden.
|
||||
alter table public.admin_users enable row level security;
|
||||
|
||||
drop policy if exists "no_anon_access_admin_users" on public.admin_users;
|
||||
-- absichtlich KEINE Policy für anon → Default deny.
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Akademischer Abschluss (z. B. "M.Eng. Elektro- und Informationstechnik"),
|
||||
-- separat vom Pronomen-Titel (academic_title: "Dr.", "Prof.", …).
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists qualification text;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Adresse in strukturierte Einzelfelder splitten, damit Format-Inkonsistenzen
|
||||
-- (Komma vs. Zeilenumbruch, PLZ-Position, Ortstrennung) ausgeschlossen sind.
|
||||
--
|
||||
-- Die alte Spalte `address` bleibt erhalten, damit bestehende Einträge nicht
|
||||
-- wegbrechen. Sie kann nach manueller Nach-Pflege der neuen Felder gedroppt
|
||||
-- werden:
|
||||
-- alter table public.employees drop column address;
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists street text,
|
||||
add column if not exists postal_code text,
|
||||
add column if not exists city text;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Vertraulicher Zusatz-Link ("Trusted-Link") pro Mitarbeiter.
|
||||
--
|
||||
-- Idee: Die öffentliche Karte (/<slug>) zeigt nur Festnetz (wie show_mobile=false
|
||||
-- vorgibt). Wer zusätzlich ein gültiges Token (?k=…) mitbringt, sieht auch die
|
||||
-- Mobilnummer — auf der Karte UND in der vCard. Token kann ablaufen, jeder
|
||||
-- Zugriff mit Token wird geloggt.
|
||||
|
||||
-- 1) Token-Spalten auf employees
|
||||
alter table public.employees
|
||||
add column if not exists trusted_token text,
|
||||
add column if not exists trusted_token_expires_at timestamptz,
|
||||
add column if not exists trusted_token_created_at timestamptz;
|
||||
|
||||
-- Eindeutigkeit, aber nur wenn gesetzt — NULL-Werte dürfen mehrfach vorkommen.
|
||||
drop index if exists public.employees_trusted_token_key;
|
||||
create unique index employees_trusted_token_key
|
||||
on public.employees(trusted_token)
|
||||
where trusted_token is not null;
|
||||
|
||||
-- 2) Audit-Log-Tabelle
|
||||
create table if not exists public.trusted_access_log (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
employee_id uuid not null references public.employees(id) on delete cascade,
|
||||
accessed_at timestamptz not null default now(),
|
||||
route text not null, -- 'card' | 'vcard'
|
||||
ip text,
|
||||
user_agent text
|
||||
);
|
||||
|
||||
create index if not exists trusted_access_log_employee_id_idx
|
||||
on public.trusted_access_log (employee_id, accessed_at desc);
|
||||
|
||||
-- 3) RLS: Tabelle komplett zu für anon. Nur service-role (Admin-Server-Code)
|
||||
-- liest und schreibt.
|
||||
alter table public.trusted_access_log enable row level security;
|
||||
|
||||
drop policy if exists "no_anon_access_trusted_access_log" on public.trusted_access_log;
|
||||
-- Absichtlich KEINE anon-Policy → Default deny. Service-Role umgeht RLS.
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Granulare Kontrolle, welche Felder der Trusted-Link zusätzlich freischaltet.
|
||||
-- Array aus String-Tokens wie 'mobile', 'linkedin', 'xing'. Leer = wie vorher
|
||||
-- (nur implizit Mobilnummer, wird in der Migration unten auf 'mobile' gehoben,
|
||||
-- damit bestehende Trusted-Links ihre bisherige Wirkung behalten).
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists trusted_reveal text[] not null default '{}';
|
||||
|
||||
-- Bestehende aktive Tokens: als "zeigt Mobilnummer" markieren, damit das
|
||||
-- Verhalten vor dem Deploy identisch bleibt.
|
||||
update public.employees
|
||||
set trusted_reveal = array['mobile']
|
||||
where trusted_token is not null
|
||||
and coalesce(array_length(trusted_reveal, 1), 0) = 0;
|
||||
@@ -0,0 +1,74 @@
|
||||
-- Branding-Konfiguration, über /admin/branding per UI editierbar.
|
||||
-- Exakt EINE Row in der Tabelle — Singleton via unique-Index erzwungen.
|
||||
-- Leere/NULL-Werte bedeuten "Fallback auf ENV bzw. Code-Defaults".
|
||||
|
||||
create table if not exists public.site_settings (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
singleton boolean not null default true,
|
||||
|
||||
-- Firma / Öffentlich
|
||||
company_name text,
|
||||
company_short text,
|
||||
claim text,
|
||||
description text,
|
||||
website text,
|
||||
|
||||
-- SEO / Metadaten
|
||||
seo_title text,
|
||||
seo_description text,
|
||||
not_found_suffix text,
|
||||
|
||||
-- Admin-Labels
|
||||
admin_app_name text,
|
||||
admin_app_subtext text,
|
||||
default_company_name text,
|
||||
calendar_placeholder text,
|
||||
|
||||
-- Theme-Farben (Hex)
|
||||
primary_color text,
|
||||
primary_dark text,
|
||||
primary_deep text,
|
||||
secondary_color text,
|
||||
secondary_dark text,
|
||||
secondary_deep text,
|
||||
|
||||
-- Assets (public URLs aus dem branding-assets Storage-Bucket)
|
||||
logo_url text,
|
||||
logo_alt text,
|
||||
favicon_url text,
|
||||
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- Exakt eine Row erlaubt: unique on (singleton=true)
|
||||
create unique index if not exists site_settings_singleton_unique
|
||||
on public.site_settings ((true)) where singleton = true;
|
||||
|
||||
-- Initiale Row anlegen, falls noch nicht da
|
||||
insert into public.site_settings (singleton)
|
||||
values (true)
|
||||
on conflict do nothing;
|
||||
|
||||
-- RLS: Branding ist public (Farben, Name, Logo) — anon darf lesen.
|
||||
alter table public.site_settings enable row level security;
|
||||
|
||||
drop policy if exists "public_read_site_settings" on public.site_settings;
|
||||
create policy "public_read_site_settings"
|
||||
on public.site_settings
|
||||
for select to anon
|
||||
using (true);
|
||||
-- Schreibzugriff bleibt bei service-role (Admin-Server-Actions).
|
||||
|
||||
-- Storage-Bucket für Logo / Favicon / Icon. Öffentlich, damit <img> und
|
||||
-- <link rel="icon"> direkt drauf zugreifen können.
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('branding-assets', 'branding-assets', true)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
-- Jeder darf lesen (Bucket ist public gesetzt, aber ein expliziter Select-
|
||||
-- Policy-Eintrag schadet nicht und macht das Verhalten explizit).
|
||||
drop policy if exists "public_read_branding_assets" on storage.objects;
|
||||
create policy "public_read_branding_assets"
|
||||
on storage.objects for select
|
||||
to anon
|
||||
using (bucket_id = 'branding-assets');
|
||||
@@ -0,0 +1,233 @@
|
||||
-- Organigramm-Modul + Modul-Toggle.
|
||||
-- ====================================================================
|
||||
-- Generisches Datenmodell, das verschiedene Compliance-Regelwerke
|
||||
-- (TSM, ISO 9001, DSGVO, …) abbilden kann. Stellen werden von Personen
|
||||
-- getrennt, damit Vakanzen ("N.N.") und Personalwechsel ohne Layout-
|
||||
-- Bruch funktionieren.
|
||||
--
|
||||
-- Tabellen:
|
||||
-- org_units — Sparte/Bereich (Strom, Gas, Finanzen, …)
|
||||
-- positions — Stelle in der Hierarchie
|
||||
-- position_assignments — Person ⇄ Stelle, mit Zeitraum
|
||||
-- deputies — Stelle ⇄ Stelle (Vertretung), mit Zeitraum
|
||||
-- external_parties — Fremdfirma / Behörde / Dienstleister
|
||||
-- service_relations — Externe ⇄ Pflichtbereich
|
||||
--
|
||||
-- Erweiterung an site_settings:
|
||||
-- enabled_modules — text[]: aktivierte Produkt-Module
|
||||
-- organigram_visibility — public | internal | trusted
|
||||
--
|
||||
-- RLS-Modell: Tabellen sind RLS-aktiv, KEINE anon-Policies. Reads
|
||||
-- laufen ausschließlich über service_role aus der App. Die Sichtbarkeits-
|
||||
-- Logik (public/internal/trusted) prüft der App-Code, nicht die DB.
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 1) Module-Toggle + Organigramm-Sichtbarkeit auf site_settings
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists enabled_modules text[] not null
|
||||
default array['business_cards']::text[];
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists organigram_visibility text not null
|
||||
default 'internal';
|
||||
|
||||
-- Sichtbarkeitswerte einschränken (idempotent)
|
||||
alter table public.site_settings
|
||||
drop constraint if exists site_settings_organigram_visibility_check;
|
||||
alter table public.site_settings
|
||||
add constraint site_settings_organigram_visibility_check
|
||||
check (organigram_visibility in ('public', 'internal', 'trusted'));
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 2) org_units — Sparte / Bereich (Hierarchie auch innerhalb möglich)
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
create table if not exists public.org_units (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
parent_id uuid references public.org_units(id) on delete restrict,
|
||||
slug text not null unique,
|
||||
name text not null,
|
||||
description text,
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint org_units_no_self_parent check (parent_id is null or parent_id <> id)
|
||||
);
|
||||
|
||||
create index if not exists org_units_parent_id_idx
|
||||
on public.org_units (parent_id);
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 3) positions — Stelle (entkoppelt von Person)
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
create table if not exists public.positions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
parent_id uuid references public.positions(id) on delete restrict,
|
||||
org_unit_id uuid references public.org_units(id) on delete set null,
|
||||
name text not null,
|
||||
short_name text,
|
||||
description text,
|
||||
-- Anzeigewert wenn keine Person zugewiesen ist (Default: 'N.N.').
|
||||
vacant_label text not null default 'N.N.',
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint positions_no_self_parent check (parent_id is null or parent_id <> id)
|
||||
);
|
||||
|
||||
create index if not exists positions_parent_id_idx
|
||||
on public.positions (parent_id);
|
||||
create index if not exists positions_org_unit_id_idx
|
||||
on public.positions (org_unit_id);
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 4) position_assignments — Person sitzt auf Stelle (mit Zeitraum)
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
create table if not exists public.position_assignments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
employee_id uuid not null references public.employees(id) on delete cascade,
|
||||
position_id uuid not null references public.positions(id) on delete cascade,
|
||||
-- Optionaler Zusatz, z.B. "kommissarisch", "i.V."
|
||||
role_label text,
|
||||
is_primary boolean not null default false,
|
||||
valid_from date not null default current_date,
|
||||
valid_to date,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint position_assignments_valid_range
|
||||
check (valid_to is null or valid_to >= valid_from)
|
||||
);
|
||||
|
||||
create index if not exists position_assignments_employee_id_idx
|
||||
on public.position_assignments (employee_id);
|
||||
create index if not exists position_assignments_position_id_idx
|
||||
on public.position_assignments (position_id);
|
||||
-- Schnelle "wer ist gerade aktiv auf Stelle X"-Queries
|
||||
create index if not exists position_assignments_active_idx
|
||||
on public.position_assignments (position_id, valid_from, valid_to);
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 5) deputies — Stelle wird vertreten von Stelle (mit Zeitraum)
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
create table if not exists public.deputies (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
position_id uuid not null references public.positions(id) on delete cascade,
|
||||
deputy_position_id uuid not null references public.positions(id) on delete cascade,
|
||||
-- Reihenfolge bei mehreren Vertretungen (1. Vertretung, 2. Vertretung, …)
|
||||
sort_order integer not null default 0,
|
||||
valid_from date not null default current_date,
|
||||
valid_to date,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint deputies_no_self check (position_id <> deputy_position_id),
|
||||
constraint deputies_valid_range
|
||||
check (valid_to is null or valid_to >= valid_from)
|
||||
);
|
||||
|
||||
create index if not exists deputies_position_id_idx
|
||||
on public.deputies (position_id);
|
||||
create index if not exists deputies_deputy_position_id_idx
|
||||
on public.deputies (deputy_position_id);
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 6) external_parties — Fremdfirma / Behörde / Dienstleister
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
create table if not exists public.external_parties (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
-- Art der Partei: dienstleister, behoerde, kooperationspartner, sonstige
|
||||
kind text not null default 'dienstleister',
|
||||
contact_name text,
|
||||
contact_email text,
|
||||
contact_phone text,
|
||||
website text,
|
||||
address text,
|
||||
notes text,
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint external_parties_kind_check
|
||||
check (kind in ('dienstleister', 'behoerde', 'kooperationspartner', 'sonstige'))
|
||||
);
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 7) service_relations — Externe deckt Pflichtbereich ab
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
create table if not exists public.service_relations (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
external_party_id uuid not null references public.external_parties(id) on delete cascade,
|
||||
-- Polymorphe Referenz: scope_kind = 'org_unit' | 'position' | 'global'
|
||||
scope_kind text not null,
|
||||
scope_id uuid,
|
||||
-- Klartext, z.B. "Brandschutzbeauftragter", "Betriebsarzt", "Personalwesen"
|
||||
service_label text not null,
|
||||
description text,
|
||||
valid_from date not null default current_date,
|
||||
valid_to date,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint service_relations_scope_kind_check
|
||||
check (scope_kind in ('org_unit', 'position', 'global')),
|
||||
constraint service_relations_scope_id_consistency
|
||||
check (
|
||||
(scope_kind = 'global' and scope_id is null)
|
||||
or (scope_kind in ('org_unit', 'position') and scope_id is not null)
|
||||
),
|
||||
constraint service_relations_valid_range
|
||||
check (valid_to is null or valid_to >= valid_from)
|
||||
);
|
||||
|
||||
create index if not exists service_relations_external_party_id_idx
|
||||
on public.service_relations (external_party_id);
|
||||
create index if not exists service_relations_scope_idx
|
||||
on public.service_relations (scope_kind, scope_id);
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 8) Trigger: updated_at automatisch aktualisieren
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
create or replace function public.set_updated_at()
|
||||
returns trigger as $$
|
||||
begin
|
||||
new.updated_at = now();
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trg_org_units_updated_at on public.org_units;
|
||||
create trigger trg_org_units_updated_at
|
||||
before update on public.org_units
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
drop trigger if exists trg_positions_updated_at on public.positions;
|
||||
create trigger trg_positions_updated_at
|
||||
before update on public.positions
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
drop trigger if exists trg_position_assignments_updated_at on public.position_assignments;
|
||||
create trigger trg_position_assignments_updated_at
|
||||
before update on public.position_assignments
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
drop trigger if exists trg_external_parties_updated_at on public.external_parties;
|
||||
create trigger trg_external_parties_updated_at
|
||||
before update on public.external_parties
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 9) RLS — alle neuen Tabellen sind defensive
|
||||
-- --------------------------------------------------------------------
|
||||
-- Reads laufen ausschließlich über service_role (Admin + öffentliche
|
||||
-- Org-Page checken Visibility selbst). KEINE anon-Policies.
|
||||
|
||||
alter table public.org_units enable row level security;
|
||||
alter table public.positions enable row level security;
|
||||
alter table public.position_assignments enable row level security;
|
||||
alter table public.deputies enable row level security;
|
||||
alter table public.external_parties enable row level security;
|
||||
alter table public.service_relations enable row level security;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Stabsstellen-Flag auf positions.
|
||||
-- ====================================================================
|
||||
-- Stabsstellen (DSB, SiBe, ISB, ImSchBe, Brandschutzbeauftragter,
|
||||
-- Techn. Führungskräfte, Controlling, Ausbilder, …) gehören organisatorisch
|
||||
-- nicht in die Linien-Hierarchie, sondern hängen seitlich am
|
||||
-- Verantwortungsträger (meist GF). Im Datenmodell behalten sie ihren
|
||||
-- parent_id (für die Anbindung), bekommen aber eine andere Edge-Visual
|
||||
-- in der Visualisierung.
|
||||
|
||||
alter table public.positions
|
||||
add column if not exists is_staff_position boolean not null default false;
|
||||
|
||||
create index if not exists positions_is_staff_position_idx
|
||||
on public.positions (is_staff_position) where is_staff_position = true;
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Compliance-Modul.
|
||||
-- ====================================================================
|
||||
-- Frameworks (DVGW G 1000, ISO 9001, DSGVO, …) liegen als YAML im Repo
|
||||
-- (compliance/frameworks/*.yaml). Die DB speichert nur:
|
||||
--
|
||||
-- 1) Welche Frameworks sind in dieser Instanz aktiv (site_settings).
|
||||
-- 2) Welche Position erfuellt welche Framework-Rolle (bindings).
|
||||
--
|
||||
-- Die App rechnet daraus den Gap-Report (welche Rollen unbesetzt sind,
|
||||
-- welche Qualifikationen fehlen, welche Bestellungen ablaufen).
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 1) Aktive Frameworks auf site_settings
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists active_compliance_frameworks text[] not null
|
||||
default array[]::text[];
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 2) compliance_role_bindings — Framework-Rolle ⇄ Stelle
|
||||
-- --------------------------------------------------------------------
|
||||
-- framework_id und role_id sind Strings (ID aus dem YAML), keine FKs:
|
||||
-- die "Quelle" der Rollen liegt im Repo, nicht in der DB. Die App
|
||||
-- validiert beim Schreiben, dass der Wert im aktuell geladenen
|
||||
-- Framework existiert.
|
||||
|
||||
create table if not exists public.compliance_role_bindings (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
framework_id text not null,
|
||||
role_id text not null,
|
||||
position_id uuid not null references public.positions(id) on delete cascade,
|
||||
appointed_on date,
|
||||
appointment_valid_to date,
|
||||
notes text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint compliance_role_bindings_appointment_range
|
||||
check (appointment_valid_to is null
|
||||
or appointed_on is null
|
||||
or appointment_valid_to >= appointed_on),
|
||||
constraint compliance_role_bindings_unique
|
||||
unique (framework_id, role_id, position_id)
|
||||
);
|
||||
|
||||
create index if not exists compliance_role_bindings_framework_idx
|
||||
on public.compliance_role_bindings (framework_id, role_id);
|
||||
create index if not exists compliance_role_bindings_position_idx
|
||||
on public.compliance_role_bindings (position_id);
|
||||
|
||||
drop trigger if exists trg_compliance_role_bindings_updated_at
|
||||
on public.compliance_role_bindings;
|
||||
create trigger trg_compliance_role_bindings_updated_at
|
||||
before update on public.compliance_role_bindings
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 3) RLS — service_role only, keine anon-Policies
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
alter table public.compliance_role_bindings enable row level security;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Vertretungen können auch Personen sein.
|
||||
-- ====================================================================
|
||||
-- Bisher: deputies.deputy_position_id (NOT NULL) — eine Stelle vertritt
|
||||
-- eine andere Stelle.
|
||||
--
|
||||
-- Erweiterung: deputy_employee_id (nullable) — eine Stelle wird von
|
||||
-- einer konkreten Person vertreten, ohne dass diese Person eine eigene
|
||||
-- Stelle haben muss. Anwendungsfall: Mitarbeiter wie P. Schmid stehen
|
||||
-- offiziell auf "Strom-Netz", vertreten aber die Leitung Strom-Netz.
|
||||
--
|
||||
-- CHECK-Constraint stellt sicher: genau eines von beiden ist gesetzt.
|
||||
|
||||
alter table public.deputies
|
||||
add column if not exists deputy_employee_id uuid
|
||||
references public.employees(id) on delete cascade;
|
||||
|
||||
alter table public.deputies
|
||||
alter column deputy_position_id drop not null;
|
||||
|
||||
alter table public.deputies
|
||||
drop constraint if exists deputies_target_check;
|
||||
|
||||
alter table public.deputies
|
||||
add constraint deputies_target_check
|
||||
check (
|
||||
(deputy_position_id is not null)::int
|
||||
+ (deputy_employee_id is not null)::int = 1
|
||||
);
|
||||
|
||||
create index if not exists deputies_deputy_employee_idx
|
||||
on public.deputies (deputy_employee_id);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Stabsstellen-Sichtbarkeit im Hauptgraph getrennt steuern.
|
||||
-- ====================================================================
|
||||
-- Bisher: jede is_staff_position landet rechts neben dem Eltern als
|
||||
-- Ellipse im Hauptgraph. Bei vielen Stabsstellen (z.B. GF mit 13 Stab-
|
||||
-- Children) wird das unleserlich.
|
||||
--
|
||||
-- Neu: show_in_chart steuert pro Stelle, ob sie im Hauptgraph
|
||||
-- gerendert wird (Linie ODER Stab-Ellipse). Stabsstellen mit
|
||||
-- show_in_chart=false erscheinen ausschließlich im Panel "Interne
|
||||
-- Dienste" unter dem Graph.
|
||||
--
|
||||
-- Linien-Stellen behalten Default true (sie gehören zwingend ins
|
||||
-- Diagramm, sonst ist die Hierarchie kaputt).
|
||||
|
||||
alter table public.positions
|
||||
add column if not exists show_in_chart boolean not null default true;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Mitarbeiter ↔ Bereich (org_unit) als direkte Zuordnung.
|
||||
-- ====================================================================
|
||||
-- Bisher: ein Mitarbeiter ist nur dann der org-Struktur zugeordnet,
|
||||
-- wenn er eine position_assignment hat. Das funktioniert für Stellen
|
||||
-- mit klarem Stelleninhaber (Leitung X, Geschäftsführer, …), aber
|
||||
-- nicht für Team-Mitglieder, die zwar zu einem Bereich gehören, aber
|
||||
-- keine eigene Stelle besetzen.
|
||||
--
|
||||
-- Neu: employees.org_unit_id (nullable) — bindet einen Mitarbeiter
|
||||
-- direkt an einen Bereich. Im Org-Chart werden alle Mitarbeiter eines
|
||||
-- Bereichs als Pillen unter der jeweiligen Bereichs-Leitung gerendert,
|
||||
-- ohne dass man sie der Leitungs-Stelle als sekundäre Zuweisung
|
||||
-- anhängen muss.
|
||||
--
|
||||
-- Die position_assignments-Tabelle bleibt für formelle Stellen-
|
||||
-- Inhaberschaft (Leitung, Vertreter, Beauftragte etc.) zuständig.
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists org_unit_id uuid
|
||||
references public.org_units(id) on delete set null;
|
||||
|
||||
create index if not exists employees_org_unit_id_idx
|
||||
on public.employees (org_unit_id);
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Geschäftsleitungs-Cluster: Stellen wie Prokurist:innen werden als
|
||||
-- Mitglied der Geschäftsleitung markiert.
|
||||
-- ====================================================================
|
||||
-- Bisher: Prokurist:innen sind Linien-Children der GF und werden eine
|
||||
-- Layer drunter gerendert. Visuell suggeriert das eine vorgesetzten-
|
||||
-- Beziehung GF→Prokurist, was zwar formal stimmt (Weisungs-Hierarchie),
|
||||
-- aber den kollegialen Charakter der Geschäftsleitung ausblendet.
|
||||
--
|
||||
-- Neu: is_executive=true → Stelle wird im Org-Chart auf gleicher y-
|
||||
-- Höhe wie die GF gerendert (links/rechts daneben), nicht eine Layer
|
||||
-- drunter. Ihre eigenen Subtrees hängen normal darunter.
|
||||
|
||||
alter table public.positions
|
||||
add column if not exists is_executive boolean not null default false;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- K1 fix: Trusted-Token + Reveal-Felder nicht über anon-API auslesbar.
|
||||
-- ====================================================================
|
||||
-- bisher: RLS auf public.employees erlaubte anon-SELECT auf alle
|
||||
-- Spalten (inkl. trusted_token, trusted_reveal). Damit war der gesamte
|
||||
-- Trusted-Link-Mechanismus wirkungslos — jeder konnte den Token via
|
||||
-- /rest/v1/employees auslesen.
|
||||
--
|
||||
-- neu: column-level grants. Die anon-Rolle darf nur safe-Spalten
|
||||
-- selektieren. Postgres lehnt SELECT mit verbotener Spalte direkt ab.
|
||||
-- Die App-Schicht (lib/employees.ts) selektiert deshalb nur die
|
||||
-- safe-Spalten. Token-Validierung läuft ausschließlich server-side
|
||||
-- mit Service-Role-Key.
|
||||
|
||||
-- Policy bleibt unverändert: anon liest nur aktive Mitarbeiter.
|
||||
drop policy if exists "public_read_active_employees" on public.employees;
|
||||
create policy "public_read_active_employees"
|
||||
on public.employees for select
|
||||
to anon
|
||||
using (active = true);
|
||||
|
||||
-- Column-level grants: zuerst alle Rechte entziehen, dann gezielt
|
||||
-- nur die unkritischen Spalten freigeben. Trusted-Token-Felder bleiben
|
||||
-- für anon unzugänglich.
|
||||
revoke all on public.employees from anon;
|
||||
grant select
|
||||
(id, slug, academic_title, qualification, first_name, last_name,
|
||||
position, company, address, street, postal_code, city, email,
|
||||
phone_office, phone_mobile, website, linkedin_url, xing_url,
|
||||
calendar_url, photo_url, show_mobile, show_linkedin, show_xing,
|
||||
active, org_unit_id, created_at, updated_at)
|
||||
on public.employees to anon;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Verwaiste service_relations entfernen, wenn Bereiche/Stellen
|
||||
-- gelöscht werden.
|
||||
-- ====================================================================
|
||||
-- service_relations.scope_id ist polymorph (org_unit oder position),
|
||||
-- daher kein direkter FK möglich. Stattdessen Trigger, die nach Delete
|
||||
-- auf den Quell-Tabellen die scope_id aufräumen.
|
||||
--
|
||||
-- Strategie: NULL-out (statt cascade-delete), damit die service_relation
|
||||
-- als Datensatz erhalten bleibt — der Admin sieht im Audit, dass die
|
||||
-- Verknüpfung früher existierte und die Quelle weg ist. Die App-UI
|
||||
-- zeigt sie als "(Bereich/Stelle gelöscht)".
|
||||
|
||||
create or replace function public.cleanup_service_relations_org_unit()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
update public.service_relations
|
||||
set scope_id = null
|
||||
where scope_kind = 'org_unit' and scope_id = OLD.id;
|
||||
return OLD;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.cleanup_service_relations_position()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
update public.service_relations
|
||||
set scope_id = null
|
||||
where scope_kind = 'position' and scope_id = OLD.id;
|
||||
return OLD;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trg_cleanup_service_relations_org_unit
|
||||
on public.org_units;
|
||||
create trigger trg_cleanup_service_relations_org_unit
|
||||
before delete on public.org_units
|
||||
for each row execute function public.cleanup_service_relations_org_unit();
|
||||
|
||||
drop trigger if exists trg_cleanup_service_relations_position
|
||||
on public.positions;
|
||||
create trigger trg_cleanup_service_relations_position
|
||||
before delete on public.positions
|
||||
for each row execute function public.cleanup_service_relations_position();
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Pro Mitarbeiter darf maximal eine Stelle als primary markiert sein.
|
||||
-- ====================================================================
|
||||
-- bisher: position_assignments.is_primary konnte mehrfach true sein —
|
||||
-- ein Mitarbeiter hatte dann widersprüchliche "Hauptstellen". Im UI
|
||||
-- wurde immer die zuletzt gefundene gewonnen, was inkonsistent war.
|
||||
--
|
||||
-- Schritt 1: bestehende Mehrfach-Primaries bereinigen — pro Mitarbeiter
|
||||
-- bleibt die mit jüngstem valid_from primary, alle anderen werden auf
|
||||
-- false gesetzt.
|
||||
--
|
||||
-- Schritt 2: partial unique index (employee_id) where is_primary = true
|
||||
-- verhindert künftige Mehrfach-Primaries auf DB-Ebene.
|
||||
|
||||
with ranked as (
|
||||
select id,
|
||||
row_number() over (
|
||||
partition by employee_id
|
||||
order by valid_from desc, created_at desc
|
||||
) as rn
|
||||
from public.position_assignments
|
||||
where is_primary = true
|
||||
)
|
||||
update public.position_assignments pa
|
||||
set is_primary = false
|
||||
from ranked r
|
||||
where pa.id = r.id and r.rn > 1;
|
||||
|
||||
create unique index if not exists position_assignments_one_primary_per_employee
|
||||
on public.position_assignments (employee_id)
|
||||
where is_primary = true;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Karten-Analytics: Views, vCard-Downloads und QR-Scans pro Karte.
|
||||
-- ====================================================================
|
||||
-- Privacy-by-default: nur das Notwendigste (Datum, Route, optional
|
||||
-- Country aus IP), kein Tracking-Cookie, keine Personen-Identifikation.
|
||||
-- Aufbewahrung: 365 Tage (Trigger / Cron später; keine harte Retention
|
||||
-- aktuell).
|
||||
|
||||
create table if not exists public.card_view_log (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
employee_id uuid not null references public.employees(id) on delete cascade,
|
||||
route text not null check (route in ('card', 'vcard', 'qr')),
|
||||
occurred_at timestamptz not null default now(),
|
||||
-- Optional: nur Country (z.B. "DE") aus dem CF/Edge-Header. Kein PII.
|
||||
country text,
|
||||
-- Referrer-Quelle gekürzt: nur Domain, keine vollständigen URLs.
|
||||
referrer_domain text
|
||||
);
|
||||
|
||||
create index if not exists card_view_log_employee_id_idx
|
||||
on public.card_view_log (employee_id, occurred_at desc);
|
||||
|
||||
create index if not exists card_view_log_occurred_at_idx
|
||||
on public.card_view_log (occurred_at desc);
|
||||
|
||||
alter table public.card_view_log enable row level security;
|
||||
-- Service-Role-only — anon hat keinen Zugriff.
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Lead-Capture: Empfänger einer Visitenkarte teilt seine eigenen
|
||||
-- Kontaktdaten zurück. Der Mitarbeiter sieht das im Admin-Backend.
|
||||
-- ====================================================================
|
||||
-- Privacy: keine PII über Trusted-Token, kein Cookie-Tracking.
|
||||
-- Lead wird mit der Karte verknüpft (employee_id) — der Mitarbeiter
|
||||
-- (oder Admin) sieht in seiner Detail-Page eine Liste der Leads.
|
||||
--
|
||||
-- Spam-Mitigation auf App-Ebene: Honeypot-Feld + Rate-Limit pro IP.
|
||||
-- Hier nur Datenmodell.
|
||||
|
||||
create table if not exists public.card_leads (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
employee_id uuid not null references public.employees(id) on delete cascade,
|
||||
-- Kontaktdaten der Person, die die Karte zurück geteilt hat:
|
||||
first_name text not null,
|
||||
last_name text not null,
|
||||
email text,
|
||||
phone text,
|
||||
company text,
|
||||
position text,
|
||||
message text,
|
||||
-- Metadata:
|
||||
created_at timestamptz not null default now(),
|
||||
source_url text, -- URL der Karte, von der aus geteilt wurde
|
||||
read_at timestamptz, -- "gelesen" markieren im Admin
|
||||
-- DSGVO: explizite Einwilligung muss true sein.
|
||||
consent_given boolean not null default false
|
||||
);
|
||||
|
||||
create index if not exists card_leads_employee_id_idx
|
||||
on public.card_leads (employee_id, created_at desc);
|
||||
|
||||
create index if not exists card_leads_unread_idx
|
||||
on public.card_leads (employee_id) where read_at is null;
|
||||
|
||||
alter table public.card_leads enable row level security;
|
||||
-- Service-Role-only — anon kann zwar inserten (über Server Action mit
|
||||
-- Service-Role), aber nicht lesen.
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Mehrsprachige Karten: Englisch-Übersetzungen für die wichtigsten
|
||||
-- Felder, die typischerweise im internationalen Kontext relevant sind
|
||||
-- (Position und Qualifikation). Default-Sprache der Karte bleibt DE
|
||||
-- — der EN-Switch passiert über `?lang=en` auf der öffentlichen URL.
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists position_en text,
|
||||
add column if not exists qualification_en text;
|
||||
|
||||
-- Anon-Rolle: Spalten freigeben (analog Migration 0014).
|
||||
grant select (position_en, qualification_en) on public.employees to anon;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Calendar-Embed: pro Mitarbeiter wählbar, ob calendar_url als Link
|
||||
-- oder als eingebettetes Iframe auf der Karte erscheint.
|
||||
-- ====================================================================
|
||||
-- Embed nur für vertrauenswürdige Domains (Cal.com, Calendly,
|
||||
-- Microsoft Bookings) — die Liste prüft die App.
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists calendar_embed boolean not null default false;
|
||||
|
||||
grant select (calendar_embed) on public.employees to anon;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Wenn ein/e Mitarbeiter:in das Unternehmen verlässt, wird die Karte
|
||||
-- typischerweise auf active=false gesetzt. Bisher: 404. Neu: optionale
|
||||
-- Nachfolger-Zuordnung — Besucher der alten URL bekommen freundlich
|
||||
-- die neue Ansprechperson angezeigt.
|
||||
-- ====================================================================
|
||||
-- successor_employee_id: Nachfolger:in (eigene Karte)
|
||||
-- successor_note: freier Text falls keine 1:1-Nachfolge
|
||||
-- (z.B. "Bitte wenden Sie sich an die Zentrale.")
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists successor_employee_id uuid
|
||||
references public.employees(id) on delete set null,
|
||||
add column if not exists successor_note text;
|
||||
|
||||
create index if not exists employees_successor_employee_id_idx
|
||||
on public.employees (successor_employee_id);
|
||||
|
||||
grant select (successor_employee_id, successor_note)
|
||||
on public.employees to anon;
|
||||
@@ -0,0 +1,56 @@
|
||||
-- GoBD-konformes Audit-Log: jede MA-Änderung wird mit Hash-Chain
|
||||
-- gespeichert, manipulationssicher, nur INSERT erlaubt.
|
||||
-- ====================================================================
|
||||
-- Format: jede Zeile enthält content_hash = SHA256 über (prev_hash +
|
||||
-- entity + entity_id + actor + timestamp + diff_json). Wenn ein
|
||||
-- Eintrag nachträglich geändert wird, bricht die Kette und das ist
|
||||
-- erkennbar.
|
||||
--
|
||||
-- entity: "employee" | "position" | "org_unit" | …
|
||||
-- entity_id: UUID des betroffenen Records
|
||||
-- action: "create" | "update" | "delete" | "activate" | "deactivate"
|
||||
-- actor: E-Mail des Admin-Users (oder "system" für Bulk/Cron)
|
||||
-- diff: JSON mit { field: { before, after } } — nur geänderte
|
||||
-- Felder
|
||||
--
|
||||
-- Aufbewahrung: 10 Jahre laut GoBD; aktuell keine harte Retention,
|
||||
-- App-Doku verweist auf manuelle Archivierung via CSV-Export.
|
||||
|
||||
create table if not exists public.change_log (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
occurred_at timestamptz not null default now(),
|
||||
entity text not null,
|
||||
entity_id uuid not null,
|
||||
action text not null,
|
||||
actor text not null,
|
||||
diff jsonb not null default '{}'::jsonb,
|
||||
prev_hash text, -- hash des vorherigen eintrags (kette)
|
||||
content_hash text not null -- sha256 dieses eintrags
|
||||
);
|
||||
|
||||
create index if not exists change_log_entity_idx
|
||||
on public.change_log (entity, entity_id, occurred_at desc);
|
||||
create index if not exists change_log_occurred_at_idx
|
||||
on public.change_log (occurred_at desc);
|
||||
|
||||
-- Schreibschutz auf Updates/Deletes — service-role-only, aber
|
||||
-- explizit als Trigger gegen Versehen.
|
||||
create or replace function public.protect_change_log()
|
||||
returns trigger language plpgsql as $$
|
||||
begin
|
||||
raise exception 'change_log ist immutable — keine Updates oder Deletes erlaubt';
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trg_protect_change_log_update on public.change_log;
|
||||
create trigger trg_protect_change_log_update
|
||||
before update on public.change_log
|
||||
for each row execute function public.protect_change_log();
|
||||
|
||||
drop trigger if exists trg_protect_change_log_delete on public.change_log;
|
||||
create trigger trg_protect_change_log_delete
|
||||
before delete on public.change_log
|
||||
for each row execute function public.protect_change_log();
|
||||
|
||||
alter table public.change_log enable row level security;
|
||||
-- Service-Role-only — anon hat gar keinen Zugriff.
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Multi-Standort: Stadtwerke / Konzerne haben oft mehrere Standorte
|
||||
-- (Werk, Verwaltung, Außenstelle). Mitarbeiter werden einem Standort
|
||||
-- zugeordnet, im Admin-Verzeichnis und Organigramm filterbar.
|
||||
-- ====================================================================
|
||||
-- Optional: ein Standort kann eine eigene Anschrift haben (Adresse,
|
||||
-- PLZ, Stadt). Die Mitarbeiter-Adresse hat aber Vorrang, wenn sie
|
||||
-- gepflegt ist.
|
||||
|
||||
create table if not exists public.locations (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
slug text not null unique,
|
||||
name text not null,
|
||||
short_name text,
|
||||
street text,
|
||||
postal_code text,
|
||||
city text,
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists locations_sort_order_idx
|
||||
on public.locations (sort_order, name);
|
||||
|
||||
drop trigger if exists trg_locations_updated_at on public.locations;
|
||||
create trigger trg_locations_updated_at
|
||||
before update on public.locations
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists location_id uuid
|
||||
references public.locations(id) on delete set null;
|
||||
|
||||
create index if not exists employees_location_id_idx
|
||||
on public.employees (location_id);
|
||||
|
||||
grant select (location_id) on public.employees to anon;
|
||||
|
||||
alter table public.locations enable row level security;
|
||||
-- Service-Role-only — anon braucht den Standort nicht direkt; wenn die
|
||||
-- Karte ihn anzeigt, hilft App-Code die Auflösung zu machen.
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Webhook-Subscriptions für externe Integrationen.
|
||||
-- ====================================================================
|
||||
-- Externe Systeme abonnieren Events ("employee.created",
|
||||
-- "employee.updated", "lead.created", etc.) und erhalten POST-Requests
|
||||
-- mit JSON-Payload. Signatur via HMAC-SHA256 mit Secret pro Endpoint.
|
||||
--
|
||||
-- Delivery-Log: jede Zustellung wird protokolliert (für Retry-
|
||||
-- Diagnose und Audit).
|
||||
|
||||
create table if not exists public.webhook_subscriptions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
url text not null,
|
||||
events text[] not null default array[]::text[],
|
||||
secret text not null, -- für HMAC-Signatur
|
||||
active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists webhook_subscriptions_active_idx
|
||||
on public.webhook_subscriptions (active) where active = true;
|
||||
|
||||
drop trigger if exists trg_webhook_subscriptions_updated_at
|
||||
on public.webhook_subscriptions;
|
||||
create trigger trg_webhook_subscriptions_updated_at
|
||||
before update on public.webhook_subscriptions
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
create table if not exists public.webhook_deliveries (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
subscription_id uuid references public.webhook_subscriptions(id)
|
||||
on delete cascade,
|
||||
event text not null,
|
||||
payload jsonb not null,
|
||||
status_code integer,
|
||||
response_body text,
|
||||
error text,
|
||||
delivered_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists webhook_deliveries_subscription_idx
|
||||
on public.webhook_deliveries (subscription_id, delivered_at desc);
|
||||
|
||||
alter table public.webhook_subscriptions enable row level security;
|
||||
alter table public.webhook_deliveries enable row level security;
|
||||
-- Service-Role-only.
|
||||
@@ -0,0 +1,83 @@
|
||||
-- Mehrere Visitenkarten pro Person.
|
||||
-- ====================================================================
|
||||
-- Use-Case: Geschäftsführer Felix Zösch hat eine Karte als „GF
|
||||
-- Stadtwerk", eine zweite als „BDEW-Ausschuss-Vertreter", eine dritte
|
||||
-- als „VKU-Delegierter". Alle Karten haben eigenen Slug, eigene
|
||||
-- Position, eigene Kontaktdaten — referenzieren aber dieselbe Person
|
||||
-- (gleicher Name, gleiches Foto, gleicher akademischer Titel).
|
||||
--
|
||||
-- Migration:
|
||||
-- 1. persons-Tabelle anlegen
|
||||
-- 2. employees.person_id (nullable FK) ergänzen
|
||||
-- 3. Backfill: pro existierendem Employee eine Person anlegen +
|
||||
-- verknüpfen (id-mäßig 1:1, semantisch erstmal eindeutig)
|
||||
-- 4. Spätere Mehrfach-Karten setzen denselben person_id auf weiteren
|
||||
-- employees-Zeilen
|
||||
--
|
||||
-- Für die Rückwärtskompatibilität bleiben die name- und photo-Felder
|
||||
-- in employees erhalten — sie sind die maßgeblichen Daten für die
|
||||
-- jeweilige Karte. Person-Felder werden für „Person-Übersicht" im
|
||||
-- Verzeichnis genutzt und können in v2 als Default-Quelle für neue
|
||||
-- Karten dienen.
|
||||
|
||||
create table if not exists public.persons (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
academic_title text,
|
||||
first_name text not null,
|
||||
last_name text not null,
|
||||
photo_url text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists persons_last_name_idx
|
||||
on public.persons (last_name, first_name);
|
||||
|
||||
drop trigger if exists trg_persons_updated_at on public.persons;
|
||||
create trigger trg_persons_updated_at
|
||||
before update on public.persons
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists person_id uuid
|
||||
references public.persons(id) on delete set null;
|
||||
|
||||
create index if not exists employees_person_id_idx
|
||||
on public.employees (person_id);
|
||||
|
||||
-- Backfill: für jeden Employee ohne person_id eine Person anlegen
|
||||
-- und verknüpfen.
|
||||
do $$
|
||||
declare
|
||||
e record;
|
||||
new_person_id uuid;
|
||||
begin
|
||||
for e in
|
||||
select id, academic_title, first_name, last_name, photo_url
|
||||
from public.employees
|
||||
where person_id is null
|
||||
loop
|
||||
insert into public.persons (academic_title, first_name, last_name, photo_url)
|
||||
values (e.academic_title, e.first_name, e.last_name, e.photo_url)
|
||||
returning id into new_person_id;
|
||||
update public.employees
|
||||
set person_id = new_person_id
|
||||
where id = e.id;
|
||||
end loop;
|
||||
end $$;
|
||||
|
||||
-- Anon-Zugriff: person_id darf gelesen werden, damit
|
||||
-- "weitere Karten dieser Person"-Liste auf öffentlicher Seite
|
||||
-- möglich ist.
|
||||
grant select (person_id) on public.employees to anon;
|
||||
|
||||
-- persons-Tabelle: anon darf basics (name, foto) lesen — wird auf
|
||||
-- der "weitere Karten"-Liste der öffentlichen Karte angezeigt.
|
||||
alter table public.persons enable row level security;
|
||||
create policy "public_read_persons"
|
||||
on public.persons for select
|
||||
to anon using (true);
|
||||
revoke all on public.persons from anon;
|
||||
grant select
|
||||
(id, academic_title, first_name, last_name, photo_url)
|
||||
on public.persons to anon;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Self-Service-Portal: Magic-Link-Auth für Mitarbeiter.
|
||||
-- ====================================================================
|
||||
-- Zweck: Mitarbeiter pflegen Foto, Mobil- & Social-Links eigenständig,
|
||||
-- ohne Admin-Login. Auth läuft über Magic-Link an die Dienst-E-Mail.
|
||||
--
|
||||
-- Flow:
|
||||
-- 1. MA gibt seine Dienst-E-Mail auf /portal ein.
|
||||
-- 2. Server findet Mitarbeiter, erzeugt Token (24 Zeichen URL-safe),
|
||||
-- schreibt Zeile in magic_link_tokens, sendet Mail mit
|
||||
-- https://team.stwhas.de/portal/auth/<token>.
|
||||
-- 3. MA klickt Link → Token wird konsumiert (used_at gesetzt) →
|
||||
-- portal_session-Cookie gesetzt → Redirect /portal/edit.
|
||||
-- 4. Auf /portal/edit darf MA nur das eigene Profil bearbeiten,
|
||||
-- nur die im Form sichtbaren Felder (siehe portal/edit/actions.ts).
|
||||
--
|
||||
-- Sicherheit:
|
||||
-- - Token gültig 30 Min, einmalig konsumierbar.
|
||||
-- - Bei Verdachts-Re-Use des Tokens (used_at != null und erneut
|
||||
-- abgerufen) → Login verweigert + Audit-Log-Eintrag.
|
||||
-- - Tabelle ist anon-blockiert (RLS), nur Service-Role greift zu.
|
||||
|
||||
create table if not exists public.magic_link_tokens (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
employee_id uuid not null references public.employees(id) on delete cascade,
|
||||
token text not null unique,
|
||||
expires_at timestamptz not null,
|
||||
used_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
ip text
|
||||
);
|
||||
|
||||
create index if not exists magic_link_tokens_token_idx
|
||||
on public.magic_link_tokens (token);
|
||||
|
||||
create index if not exists magic_link_tokens_employee_idx
|
||||
on public.magic_link_tokens (employee_id, created_at desc);
|
||||
|
||||
alter table public.magic_link_tokens enable row level security;
|
||||
-- Keine Policy → anon hat keinen Zugriff. Service-Role umgeht RLS.
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Check-out fuer Empfangs-Besucher.
|
||||
-- ====================================================================
|
||||
-- Wenn ein Besucher das Gebaeude verlaesst, kann der Empfangs-Mitarbeiter
|
||||
-- (oder eine Empfang-Tablet-Kraft) den Besuch als "ausgecheckt" markieren.
|
||||
-- card_leads dient ebenfalls als Empfangs-Buch: source_url = '/admin/empfang'
|
||||
-- (oder '/empfang' aus dem Kiosk-Modus) markiert einen Empfang-Eintrag.
|
||||
-- Die Spalte ist generisch nutzbar und steht jeden Lead zur Verfuegung,
|
||||
-- aber UI-seitig nur fuer Empfangs-Eintraege gepflegt.
|
||||
|
||||
alter table public.card_leads
|
||||
add column if not exists checked_out_at timestamptz;
|
||||
|
||||
-- Index fuer "heute offen" — schnelle Filterung der noch nicht ausgecheckten
|
||||
-- Besuche aus den letzten 24h.
|
||||
create index if not exists card_leads_open_visits_idx
|
||||
on public.card_leads (created_at desc)
|
||||
where checked_out_at is null
|
||||
and source_url in ('/admin/empfang', '/empfang');
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Empfang-Kiosk-Modus: separates Auth-Modell fuer das Tablet am
|
||||
-- Empfangstresen — KEIN Admin-Zugriff.
|
||||
-- ====================================================================
|
||||
-- Idee:
|
||||
-- * Admin legt einen Kiosk an (Name, z.B. "Empfangstresen Hauptgebaeude"),
|
||||
-- bekommt einmalig eine URL `https://teamvis.de/empfang/k/<token>`.
|
||||
-- * Diese URL wird einmal auf dem Tablet geoeffnet → Server validiert
|
||||
-- Token gegen `token_hash`, setzt langlebiges `kiosk_session`-Cookie
|
||||
-- (iron-session, 1 Jahr) und redirected auf `/empfang`.
|
||||
-- * Das Tablet bleibt damit dauerhaft im Empfangs-Modus, kann aber
|
||||
-- ueber das Cookie nichts anderes als die Empfang-Action.
|
||||
-- * Token wird nicht im Klartext gespeichert — bei Verlust einfach
|
||||
-- widerrufen (`revoked_at` setzen) und neuen Kiosk anlegen.
|
||||
|
||||
create table if not exists public.reception_kiosks (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
-- Slug zum Anzeigen in Admin-UI / Logs (z.B. "empfang-haupt").
|
||||
-- Eindeutig, klein, ohne Leerzeichen — generiert aus dem Namen.
|
||||
slug text not null unique,
|
||||
-- SHA256-Hex des Tokens. Token selbst (Base64URL ~32B) wird nur
|
||||
-- bei der Erstellung an die Admin-UI zurueckgegeben und sofort
|
||||
-- in die Aktivierungs-URL eingebaut. Danach nicht mehr abrufbar.
|
||||
token_hash text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
-- Wann zuletzt ein Request mit dem Kiosk-Cookie validiert wurde.
|
||||
-- Hilfreich um vergessene/inaktive Tablets zu erkennen.
|
||||
last_used_at timestamptz,
|
||||
-- Soft-Revoke. Eintraege bleiben fuer Audit-Zwecke erhalten;
|
||||
-- ein widerrufener Kiosk akzeptiert kein Cookie und keine URL mehr.
|
||||
revoked_at timestamptz
|
||||
);
|
||||
|
||||
create index if not exists reception_kiosks_token_hash_idx
|
||||
on public.reception_kiosks (token_hash)
|
||||
where revoked_at is null;
|
||||
|
||||
alter table public.reception_kiosks enable row level security;
|
||||
-- Default deny — Admin-Zugriff laeuft ueber Service-Role.
|
||||
@@ -0,0 +1,70 @@
|
||||
-- Empfangsbuch + GoBD-Audit-Trail.
|
||||
-- ====================================================================
|
||||
-- 1. card_leads.kiosk_id ergaenzen — welcher Kiosk hat den Eintrag erzeugt
|
||||
-- (NULL = ueber /admin/empfang von einem Admin-User).
|
||||
-- 2. reception_visit_audit als append-only Event-Log: pro Besuch wird
|
||||
-- jedes Ereignis (created / checked_out / mail_sent / mail_failed /
|
||||
-- deleted / updated) als eigene Zeile geschrieben.
|
||||
-- Loeschen / Aendern von card_leads zerstoert das Audit-Log NICHT
|
||||
-- (ON DELETE SET NULL auf lead_id; payload behaelt Snapshot).
|
||||
-- 3. DB-Trigger verbietet UPDATE und DELETE auf der Audit-Tabelle —
|
||||
-- GoBD-Anforderung "Unveraenderlichkeit nach Abschluss".
|
||||
|
||||
-- 1) Kiosk-Referenz auf card_leads
|
||||
alter table public.card_leads
|
||||
add column if not exists kiosk_id uuid
|
||||
references public.reception_kiosks(id) on delete set null;
|
||||
|
||||
create index if not exists card_leads_kiosk_idx
|
||||
on public.card_leads (kiosk_id, created_at desc)
|
||||
where kiosk_id is not null;
|
||||
|
||||
-- 2) Audit-Tabelle
|
||||
create table if not exists public.reception_visit_audit (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
lead_id uuid references public.card_leads(id) on delete set null,
|
||||
occurred_at timestamptz not null default now(),
|
||||
event_kind text not null check (event_kind in (
|
||||
'created', 'checked_out', 'mail_sent', 'mail_failed',
|
||||
'updated', 'deleted'
|
||||
)),
|
||||
actor_kind text not null check (actor_kind in ('admin','kiosk','system')),
|
||||
actor_id uuid,
|
||||
-- Vollstaendiger Snapshot der relevanten Daten, damit der Audit-Eintrag
|
||||
-- auch nach Loeschung des urspruenglichen Leads aussagekraeftig bleibt.
|
||||
-- Felder typischerweise: visitor_first_name, visitor_last_name,
|
||||
-- visitor_company, visitor_email, visitor_phone, employee_id,
|
||||
-- employee_name, reason, source, kiosk_id, mail_to, error.
|
||||
payload jsonb not null default '{}'::jsonb
|
||||
);
|
||||
|
||||
create index if not exists reception_visit_audit_occurred_idx
|
||||
on public.reception_visit_audit (occurred_at desc);
|
||||
|
||||
create index if not exists reception_visit_audit_lead_idx
|
||||
on public.reception_visit_audit (lead_id)
|
||||
where lead_id is not null;
|
||||
|
||||
create index if not exists reception_visit_audit_kind_idx
|
||||
on public.reception_visit_audit (event_kind, occurred_at desc);
|
||||
|
||||
-- 3) Unveraenderlichkeit: UPDATE und DELETE auf der Audit-Tabelle blocken.
|
||||
create or replace function public.reject_reception_audit_modify()
|
||||
returns trigger as $$
|
||||
begin
|
||||
raise exception 'reception_visit_audit ist append-only — % nicht erlaubt', tg_op;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists no_update_reception_audit on public.reception_visit_audit;
|
||||
create trigger no_update_reception_audit
|
||||
before update on public.reception_visit_audit
|
||||
for each row execute function public.reject_reception_audit_modify();
|
||||
|
||||
drop trigger if exists no_delete_reception_audit on public.reception_visit_audit;
|
||||
create trigger no_delete_reception_audit
|
||||
before delete on public.reception_visit_audit
|
||||
for each row execute function public.reject_reception_audit_modify();
|
||||
|
||||
alter table public.reception_visit_audit enable row level security;
|
||||
-- Default deny — Lesezugriff nur ueber Service-Role (Admin-Pfade).
|
||||
@@ -0,0 +1,59 @@
|
||||
-- DSGVO Art. 17 (Recht auf Vergessenwerden) fuer Empfangs-Audit-Daten.
|
||||
-- ====================================================================
|
||||
-- Problem: reception_visit_audit ist per Trigger append-only (GoBD).
|
||||
-- Bei einem Loeschanspruch nach DSGVO muessen aber personenbezogene
|
||||
-- Daten aus dem `payload`-JSON entfernt werden — die strukturelle
|
||||
-- Zeile (id, occurred_at, event_kind, actor_kind, actor_id, lead_id)
|
||||
-- bleibt fuer den Audit-Trail erhalten.
|
||||
--
|
||||
-- Loesung:
|
||||
-- 1. Spalte `redacted_at` markiert anonymisierte Eintraege.
|
||||
-- 2. UPDATE-Trigger erlaubt EINEN spezifischen UPDATE-Pfad: das
|
||||
-- Setzen von `redacted_at` UND gleichzeitiges Nullen / Redacten
|
||||
-- der payload-PII. Alle anderen UPDATEs werden weiterhin geblockt.
|
||||
-- 3. card_leads bekommt auch `redacted_at` — Anonymisierung sowohl
|
||||
-- im Hauptdatensatz als auch im Audit-Log.
|
||||
|
||||
alter table public.reception_visit_audit
|
||||
add column if not exists redacted_at timestamptz;
|
||||
|
||||
alter table public.card_leads
|
||||
add column if not exists redacted_at timestamptz;
|
||||
|
||||
-- Trigger neu definieren: erlaubt UPDATEs die NUR redacted_at + payload
|
||||
-- ändern (Anonymisierungs-Pfad). Aenderungen anderer Spalten bleiben
|
||||
-- blockiert.
|
||||
create or replace function public.reject_reception_audit_modify()
|
||||
returns trigger as $$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
raise exception 'reception_visit_audit ist append-only — DELETE nicht erlaubt';
|
||||
end if;
|
||||
-- UPDATE: nur erlauben wenn redacted_at von NULL auf NOT NULL gesetzt
|
||||
-- wird UND keine anderen identitaetsrelevanten Felder geaendert werden.
|
||||
if tg_op = 'UPDATE' then
|
||||
if old.redacted_at is not null then
|
||||
raise exception 'Eintrag wurde bereits anonymisiert — keine weiteren UPDATEs';
|
||||
end if;
|
||||
if new.redacted_at is null then
|
||||
raise exception 'reception_visit_audit-UPDATE nur fuer DSGVO-Anonymisierung erlaubt (redacted_at muss gesetzt werden)';
|
||||
end if;
|
||||
if new.id <> old.id
|
||||
or new.lead_id is distinct from old.lead_id
|
||||
or new.occurred_at <> old.occurred_at
|
||||
or new.event_kind <> old.event_kind
|
||||
or new.actor_kind <> old.actor_kind
|
||||
or new.actor_id is distinct from old.actor_id
|
||||
then
|
||||
raise exception 'reception_visit_audit-Anonymisierung darf nur payload + redacted_at aendern';
|
||||
end if;
|
||||
return new;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
-- Index fuer schnelle Filterung "noch nicht anonymisiert".
|
||||
create index if not exists reception_visit_audit_active_idx
|
||||
on public.reception_visit_audit (occurred_at desc)
|
||||
where redacted_at is null;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Admin-Self-Service-Onboarding.
|
||||
-- ====================================================================
|
||||
-- Aktuell muessen neue Admins manuell per SQL in admin_users eingefuegt
|
||||
-- werden. Dieser Workflow stellt einen Magic-Link-basierten Onboarding-
|
||||
-- Pfad bereit:
|
||||
--
|
||||
-- 1. Bestehender Admin gibt eine E-Mail-Adresse ein → Eintrag in
|
||||
-- `admin_invites` mit Token (Hash) + Ablauf (48h).
|
||||
-- 2. System schickt Mail mit Aktivierungs-URL `/admin/aktivieren/<token>`.
|
||||
-- 3. Empfaenger oeffnet URL, setzt Passwort, ggf. Anzeigename.
|
||||
-- → Eintrag in `admin_users` wird angelegt, Invite `used_at` gestempelt.
|
||||
--
|
||||
-- Zusatz: `admin_users.role` macht Berechtigungs-Differenzierung
|
||||
-- moeglich. Default = 'admin' (Vollzugriff). Spaeter ist `viewer` denkbar
|
||||
-- (lesen-only), aktuell nicht implementiert.
|
||||
|
||||
alter table public.admin_users
|
||||
add column if not exists name text,
|
||||
add column if not exists role text not null default 'admin'
|
||||
check (role in ('admin', 'viewer')),
|
||||
add column if not exists invited_by uuid references public.admin_users(id) on delete set null,
|
||||
add column if not exists last_login_at timestamptz;
|
||||
|
||||
create table if not exists public.admin_invites (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
email text not null,
|
||||
-- SHA256(token); Klartext-Token nur einmal in der Mail-URL.
|
||||
token_hash text not null,
|
||||
role text not null default 'admin'
|
||||
check (role in ('admin', 'viewer')),
|
||||
invited_by uuid references public.admin_users(id) on delete set null,
|
||||
created_at timestamptz not null default now(),
|
||||
expires_at timestamptz not null,
|
||||
used_at timestamptz,
|
||||
revoked_at timestamptz
|
||||
);
|
||||
|
||||
create index if not exists admin_invites_token_idx
|
||||
on public.admin_invites (token_hash)
|
||||
where used_at is null and revoked_at is null;
|
||||
|
||||
create index if not exists admin_invites_email_idx
|
||||
on public.admin_invites (email)
|
||||
where used_at is null and revoked_at is null;
|
||||
|
||||
alter table public.admin_invites enable row level security;
|
||||
-- Default deny — Zugriff nur ueber Service-Role (Admin-Pfade).
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 2FA fuer Admin-Login (BSI-Mindeststandard, Cyber-Versicherung).
|
||||
-- ====================================================================
|
||||
-- TOTP-Secrets werden im admin_users-Eintrag selbst gespeichert (keine
|
||||
-- separate Tabelle — 1:1-Beziehung, kein Sharding-Sinn). `totp_secret`
|
||||
-- wird Base32 gespeichert (otplib-Konvention), `totp_enabled` markiert
|
||||
-- den Aktivierungs-Status nach erfolgreichem Setup.
|
||||
-- Backup-Codes als Array von SHA256-Hashes — Klartext nur einmal beim
|
||||
-- Setup an den User zurueckgegeben, danach nicht mehr abrufbar.
|
||||
|
||||
alter table public.admin_users
|
||||
add column if not exists totp_secret text,
|
||||
add column if not exists totp_enabled boolean not null default false,
|
||||
add column if not exists totp_backup_codes text[] not null default '{}',
|
||||
add column if not exists totp_enabled_at timestamptz;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Voranmeldung von Besuchern.
|
||||
-- ====================================================================
|
||||
-- Mitarbeiter koennen im Self-Service-Portal vorab Besucher anmelden,
|
||||
-- bevor diese am Empfang erscheinen. Am Empfangs-Tablet gibt es dann
|
||||
-- eine "Erwartet"-Liste mit Ein-Klick-Eintragen — der Lead-Insert
|
||||
-- erfolgt automatisch, Tipparbeit am Tresen entfaellt.
|
||||
--
|
||||
-- Lifecycle:
|
||||
-- pending → wartet auf Eintreffen
|
||||
-- arrived → wurde am Empfang erfasst (lead_id verknuepft)
|
||||
-- expired → war 24h nach erwarteter Zeit nicht da
|
||||
-- cancelled → vom MA storniert
|
||||
|
||||
create table if not exists public.visitor_preregistrations (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
-- MA der die Voranmeldung erstellt hat
|
||||
employee_id uuid not null references public.employees(id) on delete cascade,
|
||||
-- Daten des erwarteten Besuchers
|
||||
visitor_first_name text not null,
|
||||
visitor_last_name text not null,
|
||||
visitor_company text,
|
||||
visitor_email text,
|
||||
visitor_phone text,
|
||||
reason text,
|
||||
-- Erwarteter Termin (kann null sein bei "irgendwann heute")
|
||||
expected_at timestamptz,
|
||||
-- Lifecycle
|
||||
status text not null default 'pending'
|
||||
check (status in ('pending', 'arrived', 'expired', 'cancelled')),
|
||||
-- Bei status='arrived' Verknuepfung zum entstandenen Lead
|
||||
arrived_lead_id uuid references public.card_leads(id) on delete set null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists visitor_preregistrations_employee_idx
|
||||
on public.visitor_preregistrations (employee_id, created_at desc);
|
||||
|
||||
-- Index fuer "heute pending" im Empfang-Tablet
|
||||
create index if not exists visitor_preregistrations_pending_idx
|
||||
on public.visitor_preregistrations (expected_at)
|
||||
where status = 'pending';
|
||||
|
||||
alter table public.visitor_preregistrations enable row level security;
|
||||
-- Default deny — Zugriff ueber Service-Role (Admin + Kiosk).
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Pflichtangaben (§ 35a GmbHG) + Bildmarken-Logo.
|
||||
-- ====================================================================
|
||||
-- 1) site_settings erweitern um:
|
||||
-- - legal_form (z.B. "GmbH", "UG (haftungsbeschränkt)", "AG")
|
||||
-- - register_court (z.B. "Amtsgericht Bamberg")
|
||||
-- - register_number (z.B. "HRB 4466")
|
||||
-- - managing_directors (Array, z.B. ["M.Eng. Felix Zösch", "..."])
|
||||
-- - supervisory_board_chair (optional, AG/grosse GmbH)
|
||||
-- - vat_id (z.B. "DE219286701", optional)
|
||||
-- - company_seat (Sitz der Gesellschaft, z.B. "Haßfurt")
|
||||
-- 2) site_settings.logo_mark_url für die reine Bildmarke (Symbol ohne
|
||||
-- Schriftzug) — fuer kompakte Kontexte wie E-Mail-Signaturen.
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists legal_form text,
|
||||
add column if not exists register_court text,
|
||||
add column if not exists register_number text,
|
||||
add column if not exists managing_directors text[] not null default '{}',
|
||||
add column if not exists supervisory_board_chair text,
|
||||
add column if not exists vat_id text,
|
||||
add column if not exists company_seat text,
|
||||
add column if not exists logo_mark_url text,
|
||||
add column if not exists logo_mark_alt text;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Apple Wallet Pass-Konfiguration.
|
||||
-- ====================================================================
|
||||
-- PKPass-Files werden serverseitig generiert und signiert. Dafuer
|
||||
-- braucht's pro Mandant:
|
||||
-- - Pass Type Certificate (.p12 von Apple Developer Portal, enthaelt
|
||||
-- Public + Private Key) → als Base64-String gespeichert.
|
||||
-- - Passphrase fuer den Private Key im .p12.
|
||||
-- - Pass Type Identifier (Reverse-Domain, registriert im Dev Portal).
|
||||
-- - Team Identifier (10-stellig, im Dev Portal sichtbar).
|
||||
--
|
||||
-- Apple WWDR Intermediate-Cert ist oeffentlich und wird mit dem
|
||||
-- Container ausgeliefert (lib/wallet/wwdr.ts) — keine DB-Spalte noetig.
|
||||
--
|
||||
-- Wenn alle vier Felder gesetzt sind, schaltet die Visitenkarte den
|
||||
-- "Zur Apple Wallet hinzufuegen"-Button frei.
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists apple_pass_cert_p12 text,
|
||||
add column if not exists apple_pass_passphrase text,
|
||||
add column if not exists apple_pass_type_id text,
|
||||
add column if not exists apple_team_id text;
|
||||
|
||||
-- Hinweis: apple_pass_cert_p12 enthaelt den Private Key. Die Spalte ist
|
||||
-- nur ueber Service-Role lesbar (RLS auf site_settings macht das ohnehin
|
||||
-- so — anon-Reads gehen ueber explizite Spalten-Selects in den Helpers).
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Google Wallet Pass-Konfiguration.
|
||||
-- ====================================================================
|
||||
-- Google Wallet liefert Passes als JWT-encoded "Save to Wallet"-Links.
|
||||
-- Statt einer signierten ZIP-Datei (Apple) wird ein JWT mit dem
|
||||
-- Service-Account-Private-Key signiert, der User klickt darauf, und
|
||||
-- Google generiert auf Basis des JWT-Payloads den Pass.
|
||||
--
|
||||
-- Pro Mandant brauchts:
|
||||
-- - Google Cloud Service Account (JSON-Key mit email + private_key)
|
||||
-- - Issuer ID (vom Google Pay & Wallet Console, numerisch)
|
||||
-- - Class ID Suffix (eigene Wahl, z.B. "teamvis_card")
|
||||
-- → Voll-Class-ID wird zur Laufzeit zusammengesetzt:
|
||||
-- "<issuer_id>.<class_id_suffix>"
|
||||
-- → Class wird inline im JWT mitgesendet (kein Pre-Create noetig).
|
||||
--
|
||||
-- Wenn alle Felder gesetzt sind, schaltet die Visitenkarte den
|
||||
-- "Zur Google Wallet hinzufuegen"-Button frei.
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists google_wallet_service_account_json text,
|
||||
add column if not exists google_wallet_issuer_id text,
|
||||
add column if not exists google_wallet_class_suffix text default 'teamvis_card';
|
||||
|
||||
-- Hinweis: google_wallet_service_account_json enthaelt den Private Key.
|
||||
-- Wie bei apple_pass_cert_p12 nur ueber Service-Role lesbar.
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Lead-Inbox: card_leads zum CRM-Lite ausbauen.
|
||||
-- ====================================================================
|
||||
-- Bisher waren Leads reine Eingangs-Eintraege ohne Bearbeitungsstatus.
|
||||
-- Damit MA ihre eingehenden Leads strukturiert abarbeiten koennen
|
||||
-- (Messen, Lead-Capture-Formulare, gescannte Visitenkarten):
|
||||
--
|
||||
-- - lead_status: workflow-state (new/contacted/in_progress/...)
|
||||
-- - notes: freier Notiztext (z.B. "trifft mich naechste Woche an")
|
||||
-- - tags: Kategorien (z.B. "messe-2026", "key-account")
|
||||
-- - follow_up_at: Wiedervorlage-Zeitpunkt
|
||||
-- - source_kind: woher kommt der Lead (form/reception/scan/manual)
|
||||
-- ergaenzt die bestehende source_url
|
||||
|
||||
alter table public.card_leads
|
||||
add column if not exists lead_status text not null default 'new'
|
||||
check (lead_status in ('new', 'contacted', 'in_progress', 'converted', 'dismissed')),
|
||||
add column if not exists notes text,
|
||||
add column if not exists tags text[] not null default '{}',
|
||||
add column if not exists follow_up_at timestamptz,
|
||||
add column if not exists source_kind text not null default 'form'
|
||||
check (source_kind in ('form', 'reception', 'scan', 'manual'));
|
||||
|
||||
-- Index fuer "meine offenen Leads" + "Wiedervorlagen heute".
|
||||
create index if not exists card_leads_employee_status_idx
|
||||
on public.card_leads (employee_id, lead_status, created_at desc);
|
||||
create index if not exists card_leads_follow_up_idx
|
||||
on public.card_leads (follow_up_at)
|
||||
where follow_up_at is not null and lead_status not in ('converted', 'dismissed');
|
||||
@@ -0,0 +1,22 @@
|
||||
-- AI-Provider-Konfiguration fuer den Card-Scanner und kuenftige
|
||||
-- AI-gestuetzte Features.
|
||||
-- ====================================================================
|
||||
-- Statt nur ANTHROPIC_API_KEY in der ENV: pro Mandant via Admin-UI
|
||||
-- konfigurierbar. Drei Provider unterstuetzt:
|
||||
-- - anthropic (Claude — Default)
|
||||
-- - openai (GPT)
|
||||
-- - openrouter (OpenAI-compat API, Multi-Vendor)
|
||||
--
|
||||
-- ai_active_provider gibt an welcher Provider gerade verwendet wird.
|
||||
-- Keys + Modelle werden pro Provider separat gespeichert (Multi-
|
||||
-- Provider-Setup parallel moeglich, switching per Toggle).
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists ai_active_provider text not null default 'anthropic'
|
||||
check (ai_active_provider in ('anthropic', 'openai', 'openrouter')),
|
||||
add column if not exists ai_anthropic_key text,
|
||||
add column if not exists ai_anthropic_model text default 'claude-sonnet-4-6',
|
||||
add column if not exists ai_openai_key text,
|
||||
add column if not exists ai_openai_model text default 'gpt-4o-mini',
|
||||
add column if not exists ai_openrouter_key text,
|
||||
add column if not exists ai_openrouter_model text default 'anthropic/claude-sonnet-4.6';
|
||||
@@ -0,0 +1,54 @@
|
||||
-- ====================================================================
|
||||
-- 0039_compliance_employee_bindings — Person-zentrierte Compliance-Bindings
|
||||
-- ====================================================================
|
||||
-- Bisher konnten Compliance-Rollen nur an Stellen (positions) gebunden
|
||||
-- werden. Manche Pflichtrollen sind aber persönliche Bestellungen, die
|
||||
-- nicht an einer eigenen Stelle hängen (z.B. VEFK, Stv. VEFK,
|
||||
-- Brandschutzhelfer:in, einzelne Schaltberechtigte).
|
||||
--
|
||||
-- Diese Migration erweitert compliance_role_bindings um eine optionale
|
||||
-- employee_id. Pro Binding wird entweder eine Position oder ein
|
||||
-- Mitarbeiter referenziert — nie beides, nie keines.
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
-- position_id wird optional
|
||||
alter table public.compliance_role_bindings
|
||||
alter column position_id drop not null;
|
||||
|
||||
-- Neue Spalte für Personen-Bindings
|
||||
alter table public.compliance_role_bindings
|
||||
add column if not exists employee_id uuid
|
||||
references public.employees(id) on delete cascade;
|
||||
|
||||
-- XOR-Constraint: genau einer von position_id / employee_id muss gesetzt sein
|
||||
alter table public.compliance_role_bindings
|
||||
drop constraint if exists compliance_role_bindings_subject_check;
|
||||
alter table public.compliance_role_bindings
|
||||
add constraint compliance_role_bindings_subject_check
|
||||
check (
|
||||
(position_id is not null and employee_id is null)
|
||||
or (position_id is null and employee_id is not null)
|
||||
);
|
||||
|
||||
-- Unique-Constraint erweitern: jetzt auch für employee-Bindings eindeutig
|
||||
-- (Drop+Recreate, weil die alte Definition nur position_id berücksichtigt
|
||||
-- hat — null-Werte würden mehrfach erlaubt sein.)
|
||||
alter table public.compliance_role_bindings
|
||||
drop constraint if exists compliance_role_bindings_unique;
|
||||
|
||||
create unique index if not exists compliance_role_bindings_unique_position
|
||||
on public.compliance_role_bindings (framework_id, role_id, position_id)
|
||||
where position_id is not null;
|
||||
|
||||
create unique index if not exists compliance_role_bindings_unique_employee
|
||||
on public.compliance_role_bindings (framework_id, role_id, employee_id)
|
||||
where employee_id is not null;
|
||||
|
||||
-- Lookup-Index auf employee_id (analog zu position_id)
|
||||
create index if not exists compliance_role_bindings_employee_idx
|
||||
on public.compliance_role_bindings (employee_id);
|
||||
|
||||
comment on column public.compliance_role_bindings.position_id is
|
||||
'Wenn gesetzt: Rolle hängt an einer Stelle — Inhaber der Stelle ist Compliance-Träger. Sich gegenseitig ausschließend mit employee_id.';
|
||||
comment on column public.compliance_role_bindings.employee_id is
|
||||
'Wenn gesetzt: Rolle ist eine persönliche Bestellung an einen Mitarbeiter (z.B. VEFK, Brandschutzhelfer:in). Sich gegenseitig ausschließend mit position_id.';
|
||||
@@ -0,0 +1,25 @@
|
||||
-- ====================================================================
|
||||
-- 0040_watermark — separates Watermark-Asset pro Mandant
|
||||
-- ====================================================================
|
||||
-- Bisher wurde im Visitenkarten-Hero die Bildmarke (logo_mark_url) als
|
||||
-- Watermark eingeblendet. Die Bildmarke wird aber auch in der Signatur,
|
||||
-- im QR-Generator und an anderen Stellen genutzt — dort braucht sie
|
||||
-- typischerweise einen gefüllten Hintergrund, damit sie gut sichtbar
|
||||
-- ist. Als Watermark im Hero wirkt ein gefülltes Block-Logo dagegen
|
||||
-- als Quadrat-Block, nicht als freistehendes Symbol.
|
||||
--
|
||||
-- Diese Migration fügt ein separates watermark_url + watermark_alt
|
||||
-- hinzu. Wenn das Feld leer ist, fällt die Render-Logik auf
|
||||
-- logo_mark_url zurück (Verhalten wie bisher), dann auf das generische
|
||||
-- Wellen-SVG. Wenn watermark_url gesetzt ist, wird es bevorzugt — der
|
||||
-- Mandant kann dann eine freistehende Outline-Variante hochladen.
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists watermark_url text,
|
||||
add column if not exists watermark_alt text;
|
||||
|
||||
comment on column public.site_settings.watermark_url is
|
||||
'Optionales SVG/PNG für den Visitenkarten-Hero-Watermark. Sollte freistehend (transparenter Hintergrund) sein. Fallback: logo_mark_url, dann generisches Wellen-SVG.';
|
||||
comment on column public.site_settings.watermark_alt is
|
||||
'Alt-Text für das Watermark (aria-label). Watermark ist standardmäßig dekorativ; das Feld ist optional.';
|
||||
@@ -0,0 +1,40 @@
|
||||
-- ====================================================================
|
||||
-- 0041_phone_integration — Anbindung Telefonanlagen (3CX & generisch)
|
||||
-- ====================================================================
|
||||
-- Generisches Provider-Pattern: pro Mandant ein Telefon-Provider
|
||||
-- (aktuell 3CX im Fokus, weitere Adapter via lib/phone/providers/*.ts
|
||||
-- nachruestbar). Tokens und Geheimnisse liegen in site_settings;
|
||||
-- Endpunkte sind uniform unter /api/phone/*.
|
||||
--
|
||||
-- Phase 1 nutzt phone_lookup_token (Bearer fuer Inbound-Lookup).
|
||||
-- Phase 2 nutzt phone_webhook_secret (HMAC fuer Call-Event-POSTs).
|
||||
-- Phase 3+4 (Click-to-Call / Presence) brauchen phone_api_url +
|
||||
-- phone_api_token, die wir gleich mitanlegen.
|
||||
--
|
||||
-- employees.phone_extension mapping zur Anlage (z.B. "333" als
|
||||
-- 3CX-Extension fuer felix.zoesch). Erforderlich fuer Click-to-Call
|
||||
-- und Presence-Lookup.
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists phone_provider text,
|
||||
add column if not exists phone_api_url text,
|
||||
add column if not exists phone_api_token text,
|
||||
add column if not exists phone_lookup_token text,
|
||||
add column if not exists phone_webhook_secret text;
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists phone_extension text;
|
||||
|
||||
comment on column public.site_settings.phone_provider is
|
||||
'Telefonanlagen-Adapter: ''3cx'' | ''sipgate'' | ''asterisk'' | ''placetel'' | null. Bestimmt Format der Lookup-Antwort + Webhook-Erwartung.';
|
||||
comment on column public.site_settings.phone_api_url is
|
||||
'Base-URL der Anlagen-API (z.B. https://stwhas.3cx.eu/xapi/v1). Fuer Click-to-Call + Presence-Polling.';
|
||||
comment on column public.site_settings.phone_api_token is
|
||||
'Token fuer ausgehende Calls an die Anlagen-API. Nur Service-Role-lesbar.';
|
||||
comment on column public.site_settings.phone_lookup_token is
|
||||
'Bearer-Token, das die Anlage beim GET /api/phone/lookup mitschicken muss. Frei generierbar im Admin.';
|
||||
comment on column public.site_settings.phone_webhook_secret is
|
||||
'HMAC-Secret zur Validierung eingehender Call-Event-Webhooks (POST /api/phone/call-event). Frei generierbar im Admin.';
|
||||
comment on column public.employees.phone_extension is
|
||||
'Telefon-Extension dieser Mitarbeiterin/dieses Mitarbeiters in der Anlage (z.B. "333"). Optional — nur fuer Click-to-Call + Presence relevant.';
|
||||
@@ -0,0 +1,31 @@
|
||||
-- ====================================================================
|
||||
-- 0042_license — License-Foundation
|
||||
-- ====================================================================
|
||||
-- Speichert pro Instanz/Mandant einen signierten License-JWT. Inhalt:
|
||||
-- - tier (free/starter/business/enterprise)
|
||||
-- - max_employees, ma_overage_price_eur
|
||||
-- - enabled modules + per-module limits
|
||||
-- - hosting (cloud | self-hosted), branding_level
|
||||
-- - exp
|
||||
--
|
||||
-- Der JWT wird im Application-Code per public key validiert (lib/
|
||||
-- license.ts). Bei fehlendem oder ungueltigem Key faellt die Instanz
|
||||
-- auf Free-Tier-Defaults zurueck (10 MA, Core only).
|
||||
--
|
||||
-- license_status ist ein gecachter Wert ('active' | 'grace' | 'expired'
|
||||
-- | 'invalid'), license_checked_at der letzte Pruef-Zeitpunkt. Beides
|
||||
-- nicht-load-bearing — wird beim periodischen Check refresht. Idee:
|
||||
-- Render-Pages koennen ohne JWT-Decode den Status anzeigen.
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists license_key text,
|
||||
add column if not exists license_status text,
|
||||
add column if not exists license_checked_at timestamptz;
|
||||
|
||||
comment on column public.site_settings.license_key is
|
||||
'Signierter License-JWT (RS256). Wird per lib/license.ts gegen den Public Key validiert. NULL → Free-Tier-Defaults.';
|
||||
comment on column public.site_settings.license_status is
|
||||
'Gecachter Status: active | grace | expired | invalid | null. Wird beim periodischen Check aktualisiert.';
|
||||
comment on column public.site_settings.license_checked_at is
|
||||
'Letzter Validierungs-Zeitpunkt. Wird genutzt um Refresh-Cycles zu drosseln.';
|
||||
@@ -0,0 +1,44 @@
|
||||
-- 0043: site_settings — Spalten-Grant für anon einschränken (SECURITY-FIX)
|
||||
--
|
||||
-- Bisher: Policy `public_read_site_settings` (using true) erlaubt anon das
|
||||
-- Lesen der site_settings-Zeile. RLS-Policies filtern aber KEINE Spalten —
|
||||
-- das macht nur GRANT. site_settings wurde (anders als employees in 0014,
|
||||
-- persons in 0025) nie spaltenweise eingeschränkt. Dadurch konnte jeder mit
|
||||
-- dem öffentlichen anon-Key per Supabase-REST die Geheimnisse auslesen:
|
||||
-- Apple-Wallet-Zertifikat (.p12) + Passphrase, Google-Service-Account-JSON,
|
||||
-- AI-API-Keys (Anthropic/OpenAI/OpenRouter), Phone-Tokens (api/lookup/
|
||||
-- webhook) und der License-Key.
|
||||
--
|
||||
-- Robuste Umsetzung (Block-Liste statt fester Spalten-Liste): anon den
|
||||
-- Vollzugriff entziehen, dann per Katalog-Lookup ALLE existierenden Spalten
|
||||
-- AUSSER den Geheimnis-Spalten freigeben. Funktioniert unabhängig vom
|
||||
-- Schema-Stand der jeweiligen Mandanten-DB (Prod, Demo, künftige Kunden) —
|
||||
-- eine feste Spalten-Liste bricht, sobald eine DB nicht voll migriert ist.
|
||||
--
|
||||
-- WICHTIG (Betrieb): Die bereits exponierten Geheimnisse gelten als
|
||||
-- kompromittiert und müssen rotiert werden (OpenRouter-Key, License-Key,
|
||||
-- Apple-Pass-Zertifikat + Passphrase, ggf. weitere befüllte Keys/Tokens).
|
||||
-- HINWEIS für neue Geheimnis-Spalten: unbedingt in die Block-Liste unten
|
||||
-- aufnehmen, sonst werden sie automatisch anon-lesbar.
|
||||
|
||||
revoke all on public.site_settings from anon;
|
||||
|
||||
do $$
|
||||
declare
|
||||
v_cols text;
|
||||
begin
|
||||
select string_agg(quote_ident(column_name), ', ')
|
||||
into v_cols
|
||||
from information_schema.columns
|
||||
where table_schema = 'public'
|
||||
and table_name = 'site_settings'
|
||||
and column_name not in (
|
||||
-- Geheimnis-Spalten — NUR über Service-Role lesbar:
|
||||
'ai_anthropic_key', 'ai_openai_key', 'ai_openrouter_key',
|
||||
'apple_pass_cert_p12', 'apple_pass_passphrase', 'apple_pass_type_id',
|
||||
'google_wallet_service_account_json',
|
||||
'phone_api_token', 'phone_lookup_token', 'phone_webhook_secret',
|
||||
'license_key'
|
||||
);
|
||||
execute 'grant select (' || v_cols || ') on public.site_settings to anon';
|
||||
end $$;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Storage-Bucket für Mitarbeiter-Fotos. Bisher musste dieser Bucket bei
|
||||
-- jeder neuen Instanz manuell in Supabase Studio angelegt werden — das war
|
||||
-- die einzige Storage-Ressource ohne Migration (branding-assets kommt aus
|
||||
-- 0006). Damit ein frisches Onboarding rein über die Migrationen läuft,
|
||||
-- legen wir den Bucket hier idempotent an.
|
||||
--
|
||||
-- Öffentlich, weil die Karten-Fotos via next/image direkt aus der
|
||||
-- public-Storage-Route geladen werden (Host in next.config.ts whitelisted).
|
||||
-- Uploads laufen ausschließlich über die Service-Role (Server Action
|
||||
-- saveEmployee / Portal-Upload) und umgehen RLS — daher keine Insert-Policy
|
||||
-- für anon nötig, nur Lesezugriff.
|
||||
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('employee-photos', 'employee-photos', true)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
drop policy if exists "public_read_employee_photos" on storage.objects;
|
||||
create policy "public_read_employee_photos"
|
||||
on storage.objects for select
|
||||
to anon
|
||||
using (bucket_id = 'employee-photos');
|
||||
@@ -0,0 +1,25 @@
|
||||
-- H2: Sichtbarkeits-Flags auf DB-Ebene durchsetzen.
|
||||
--
|
||||
-- phone_mobile, linkedin_url und xing_url waren spaltenweise an anon
|
||||
-- GRANTed (Migration 0014 ff.). Die Opt-out-Flags show_mobile/show_linkedin/
|
||||
-- show_xing filterten diese Felder aber NUR in der Render-Schicht
|
||||
-- (VisitingCard) und im vCard-Builder — über den öffentlichen anon-Key
|
||||
-- liessen sie sich per Supabase-REST direkt für ALLE aktiven Mitarbeiter
|
||||
-- abgreifen, unabhaengig vom Opt-out. Das ist dieselbe Klasse wie K-1:
|
||||
-- RLS-Policies und Grants filtern KEINE Spalten bedingt.
|
||||
--
|
||||
-- Ab jetzt liest die App diese Felder ausschliesslich serverseitig ueber
|
||||
-- getActiveEmployeeBySlug (auf createServiceClient umgestellt) und wendet
|
||||
-- die show_*-Flags weiterhin im Server-Render/vCard an. anon braucht die
|
||||
-- Felder daher nicht mehr.
|
||||
--
|
||||
-- successor_employee_id / successor_note werden ohnehin nur ueber
|
||||
-- getSuccessorInfoForSlug (service-role) gelesen und sind hier mit
|
||||
-- abgeraeumt (vorher unnoetig anon-lesbar, Low-Befund).
|
||||
--
|
||||
-- REVOKE auf nicht vorhandene Spalten-Grants ist ein No-op (NOTICE, kein
|
||||
-- Fehler) — die Migration ist damit auch auf Instanzen ohne den vollen
|
||||
-- Grant unkritisch.
|
||||
|
||||
revoke select (phone_mobile, linkedin_url, xing_url, successor_employee_id, successor_note)
|
||||
on public.employees from anon;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- =====================================================================
|
||||
-- 0046 — Login-Code fürs Self-Service-Portal (zusätzlich zum Magic-Link)
|
||||
-- =====================================================================
|
||||
-- Der klickbare Magic-Link öffnet im System-Browser, nicht im installierten
|
||||
-- Standalone-PWA — Browser und PWA haben getrennte Cookie-Speicher, die im
|
||||
-- Browser gesetzte Session landet also nie im PWA. Ein 6-stelliger Code, den
|
||||
-- man IM PWA eintippt, verifiziert aus dem PWA-Kontext und setzt das Cookie
|
||||
-- dort, wo es hingehört.
|
||||
--
|
||||
-- `attempts` begrenzt Brute-Force auf den 6-stelligen Code (zusätzlich zum
|
||||
-- Rate-Limit in der Server-Action): nach zu vielen Fehlversuchen wird der
|
||||
-- Token entwertet.
|
||||
|
||||
alter table public.magic_link_tokens
|
||||
add column if not exists code text,
|
||||
add column if not exists attempts integer not null default 0;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- =====================================================================
|
||||
-- 0047 — Erzwungene Passwortänderung beim ersten Login
|
||||
-- =====================================================================
|
||||
-- Frische Admins werden via create-admin.mjs mit einem Temp-Passwort
|
||||
-- angelegt. Bisher konnte dieses Passwort unbegrenzt bestehen bleiben.
|
||||
-- Mit diesem Flag zwingt das Admin-Backend zur Änderung, bevor man
|
||||
-- weiterarbeiten kann (siehe app/admin/layout.tsx). Wird beim erfolgreichen
|
||||
-- Passwortwechsel zurückgesetzt.
|
||||
--
|
||||
-- Default false: bestehende Admins (Prod/Demo) sind NICHT betroffen.
|
||||
|
||||
alter table public.admin_users
|
||||
add column if not exists must_change_password boolean not null default false;
|
||||
@@ -0,0 +1,145 @@
|
||||
-- =====================================================================
|
||||
-- 0048 — Modul „Stellenbeschreibungen"
|
||||
-- =====================================================================
|
||||
-- KI- + textbaustein-gestützte, compliance-konforme Stellenbeschreibungen.
|
||||
-- Hängt an den bestehenden Stellen (positions) des Organigramm-Moduls;
|
||||
-- der compliance-relevante Teil wird datengetrieben aus
|
||||
-- compliance_role_bindings + den Framework-YAMLs gespeist (nicht von der KI).
|
||||
--
|
||||
-- Drei Tabellen:
|
||||
-- job_description_blocks — wiederverwendbare Textbausteine
|
||||
-- job_descriptions — 1 Beschreibung pro Stelle (aktueller Stand)
|
||||
-- job_description_versions — append-only Historie bei jeder Freigabe
|
||||
--
|
||||
-- Alle Tabellen sind admin-only (RLS an, KEINE anon-Policy → Default deny);
|
||||
-- gelesen/geschrieben wird ausschließlich per Service-Role.
|
||||
|
||||
-- ── Textbausteine ────────────────────────────────────────────────────
|
||||
create table if not exists public.job_description_blocks (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
slug text not null unique,
|
||||
category text not null default 'allgemein',
|
||||
title text not null,
|
||||
body text not null default '',
|
||||
-- Steuert die automatische Vorauswahl im Editor.
|
||||
is_default boolean not null default false,
|
||||
-- Herkunft für aus Compliance-Frameworks abgeleitete Bausteine
|
||||
-- (rein informativ; der verbindliche Compliance-Abschnitt kommt live
|
||||
-- aus den Bindings, nicht aus diesen Bausteinen).
|
||||
source_framework_id text,
|
||||
source_role_id text,
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists job_description_blocks_category_idx
|
||||
on public.job_description_blocks (category, sort_order);
|
||||
|
||||
-- ── Stellenbeschreibungen (1 pro Stelle) ─────────────────────────────
|
||||
create table if not exists public.job_descriptions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
position_id uuid not null unique
|
||||
references public.positions(id) on delete cascade,
|
||||
title text not null default '',
|
||||
-- Strukturierte Abschnitte: { zweck, einordnung, aufgaben[],
|
||||
-- befugnisse[], anforderungen[], vertretung, ... } als JSON.
|
||||
content_json jsonb not null default '{}'::jsonb,
|
||||
status text not null default 'draft'
|
||||
check (status in ('draft', 'review', 'approved')),
|
||||
generated_by_ai boolean not null default false,
|
||||
model_used text,
|
||||
version integer not null default 1,
|
||||
approved_by text,
|
||||
approved_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists job_descriptions_position_idx
|
||||
on public.job_descriptions (position_id);
|
||||
|
||||
-- ── Versions-Historie (Snapshot bei „Freigeben") ─────────────────────
|
||||
create table if not exists public.job_description_versions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
job_description_id uuid not null
|
||||
references public.job_descriptions(id) on delete cascade,
|
||||
version integer not null,
|
||||
title text not null default '',
|
||||
content_json jsonb not null default '{}'::jsonb,
|
||||
created_by text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists job_description_versions_jd_idx
|
||||
on public.job_description_versions (job_description_id, version desc);
|
||||
|
||||
-- ── updated_at-Trigger (set_updated_at existiert seit 0023) ───────────
|
||||
drop trigger if exists trg_job_description_blocks_updated_at
|
||||
on public.job_description_blocks;
|
||||
create trigger trg_job_description_blocks_updated_at
|
||||
before update on public.job_description_blocks
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
drop trigger if exists trg_job_descriptions_updated_at
|
||||
on public.job_descriptions;
|
||||
create trigger trg_job_descriptions_updated_at
|
||||
before update on public.job_descriptions
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
-- ── RLS: admin-only (Default deny, kein anon-Grant) ──────────────────
|
||||
alter table public.job_description_blocks enable row level security;
|
||||
alter table public.job_descriptions enable row level security;
|
||||
alter table public.job_description_versions enable row level security;
|
||||
|
||||
-- Service-Role (umgeht RLS) braucht trotzdem Tabellen-Grants. Auf
|
||||
-- Self-Host-Instanzen sind Default-Privileges nicht garantiert — daher
|
||||
-- explizit. Bewusst NICHT an anon/authenticated (admin-only).
|
||||
grant select, insert, update, delete on
|
||||
public.job_description_blocks,
|
||||
public.job_descriptions,
|
||||
public.job_description_versions
|
||||
to service_role;
|
||||
|
||||
-- ── Modul-Default: „job_descriptions" in enabled_modules aufnehmen ────
|
||||
-- Bestandsinstanzen, die das Modul (noch) nicht kennen, bleiben unberührt
|
||||
-- — der Operator schaltet es unter /admin/funktionen frei. Hier wird nur
|
||||
-- der Spalten-Default für ganz neue Instanzen erweitert.
|
||||
do $$
|
||||
begin
|
||||
if exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'site_settings'
|
||||
and column_name = 'enabled_modules'
|
||||
) then
|
||||
alter table public.site_settings
|
||||
alter column enabled_modules
|
||||
set default array['business_cards']::text[];
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- ── Seed: generische Standard-Textbausteine ──────────────────────────
|
||||
-- Out-of-the-box-Startset. Idempotent über den slug. Operatoren können
|
||||
-- sie unter /admin/stellenbeschreibungen/bausteine anpassen oder löschen.
|
||||
insert into public.job_description_blocks
|
||||
(slug, category, title, body, is_default, sort_order)
|
||||
values
|
||||
('allgemeine-pflichten', 'Allgemein', 'Allgemeine Pflichten',
|
||||
'Wahrnehmung der übertragenen Aufgaben mit der gebotenen Sorgfalt; Einhaltung der betrieblichen Anweisungen, Richtlinien und gesetzlichen Vorgaben; wirtschaftlicher und schonender Umgang mit Betriebsmitteln.',
|
||||
true, 0),
|
||||
('zusammenarbeit', 'Allgemein', 'Zusammenarbeit & Kommunikation',
|
||||
'Konstruktive Zusammenarbeit mit vor- und nachgelagerten Bereichen; rechtzeitige Information der Vorgesetzten über wesentliche Vorgänge; serviceorientiertes Auftreten gegenüber Kund:innen und Bürger:innen.',
|
||||
true, 1),
|
||||
('arbeitssicherheit', 'Arbeitsschutz', 'Arbeitssicherheit & Gesundheitsschutz',
|
||||
'Einhaltung der Arbeitsschutz- und Unfallverhütungsvorschriften (DGUV); Nutzung der vorgeschriebenen persönlichen Schutzausrüstung; unverzügliche Meldung von Gefährdungen, Beinaheunfällen und Mängeln.',
|
||||
true, 2),
|
||||
('datenschutz', 'Datenschutz', 'Datenschutz-Grundpflichten',
|
||||
'Vertraulicher Umgang mit personenbezogenen Daten gemäß DSGVO und BDSG; Datenverarbeitung ausschließlich im Rahmen der zugewiesenen Aufgaben; Wahrung des Datengeheimnisses auch über das Beschäftigungsverhältnis hinaus.',
|
||||
true, 3),
|
||||
('informationssicherheit', 'Informationssicherheit', 'Informationssicherheit',
|
||||
'Beachtung der Informationssicherheits-Richtlinien (ISMS nach ISO/IEC 27001); sorgsamer Umgang mit Zugangsdaten und Informationswerten; Meldung von Sicherheitsvorfällen an die zuständige Stelle.',
|
||||
false, 4),
|
||||
('vertretungsregelung', 'Allgemein', 'Vertretungsregelung',
|
||||
'Im Verhinderungsfall wird die Stelle durch die benannte Vertretung wahrgenommen. Eine geordnete Übergabe laufender Vorgänge ist sicherzustellen.',
|
||||
false, 5)
|
||||
on conflict (slug) do nothing;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- =====================================================================
|
||||
-- 0049 — Stellenbeschreibungen: Metadaten (Tarif/Entgelt, Gültigkeit)
|
||||
-- =====================================================================
|
||||
-- Erweitert job_descriptions um deutsche Stellenbeschreibungs-Standardfelder:
|
||||
-- Tarifvertrag + Entgeltgruppe (Default-Kontext Versorgungsbetriebe: TV-V),
|
||||
-- Stellenumfang (VZÄ/%), Befristung, sowie Gültigkeit + Review-Zyklus.
|
||||
-- Alle additiv + nullable → Bestandsdaten unberührt.
|
||||
|
||||
alter table public.job_descriptions
|
||||
add column if not exists tarifvertrag text, -- z.B. 'TV-V', 'TVöD', 'AT'
|
||||
add column if not exists entgeltgruppe text, -- z.B. 'EG 9'
|
||||
add column if not exists stellenumfang text, -- z.B. '100 % (Vollzeit)'
|
||||
add column if not exists befristung text, -- z.B. 'unbefristet'
|
||||
add column if not exists valid_from date, -- gültig ab
|
||||
add column if not exists review_due date; -- nächste Überprüfung
|
||||
@@ -0,0 +1,25 @@
|
||||
-- ====================================================================
|
||||
-- 0050_employee_extra_fields — Frei definierbare Zusatzangaben
|
||||
-- ====================================================================
|
||||
-- Mitarbeiter können beliebig viele weitere Telefonnummern und private
|
||||
-- Angaben hinterlegen, jeweils ein-/ausblendbar. Statt vieler fester
|
||||
-- Spalten eine JSONB-Liste:
|
||||
-- [{ "id": "f0", "kind": "phone|mobile|email|address|text",
|
||||
-- "label": "Festnetz privat", "value": "...", "visible": true }, …]
|
||||
--
|
||||
-- Sichtbarkeit wird wie bei show_mobile serverseitig im Render/vCard
|
||||
-- gefiltert (visibleExtraFields). Die öffentliche Karte liest seit 0045
|
||||
-- ohnehin per Service-Role — anon braucht die Spalte nicht und soll sie
|
||||
-- (ausgeblendete, ggf. private Werte) auch nicht lesen können.
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists extra_fields jsonb not null default '[]'::jsonb;
|
||||
|
||||
comment on column public.employees.extra_fields is
|
||||
'Frei definierbare Zusatzangaben (weitere Telefonnummern, private Angaben). '
|
||||
'Liste von {id, kind, label, value, visible}. Sichtbarkeit pro Eintrag; '
|
||||
'serverseitige Filterung im Render/vCard. Siehe lib/extra-fields.ts.';
|
||||
|
||||
-- anon darf die Spalte nicht direkt lesen (könnte ausgeblendete/private
|
||||
-- Einträge enthalten). REVOKE auf nicht vorhandenen Grant ist ein No-op.
|
||||
revoke select (extra_fields) on public.employees from anon;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- ====================================================================
|
||||
-- 0051_organigram_versions — Versionierung des Organigramms
|
||||
-- ====================================================================
|
||||
-- Audit-Anforderung: Prüfer wollen jederzeit die aktuelle, datierte
|
||||
-- Version sehen und ältere Stände nachvollziehen können.
|
||||
--
|
||||
-- Modell (Hybrid):
|
||||
-- 1) organigram_versions: nummerierte, UNVERÄNDERLICHE Snapshots des
|
||||
-- kompletten Organigramm-Stands (JSONB), erzeugt bei „Version
|
||||
-- veröffentlichen". Analog job_description_versions (0048).
|
||||
-- 2) change_log (0022): erfasst zusätzlich jede Einzeländerung an
|
||||
-- positions/org_units/assignments/deputies/external_parties/
|
||||
-- service_relations (Hash-gesichert) — für die lückenlose
|
||||
-- Nachvollziehbarkeit der Änderungen zwischen zwei Versionen.
|
||||
|
||||
create table if not exists public.organigram_versions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
version_number integer not null,
|
||||
-- Vollständiger OrganigramSnapshot (lib/organigram-tree.ts) als JSON,
|
||||
-- inkl. zum Zeitpunkt der Freigabe aufgelöster Personennamen — so ist
|
||||
-- der Stand reproduzierbar, auch wenn Stammdaten sich später ändern.
|
||||
snapshot jsonb not null,
|
||||
published_at timestamptz not null default now(),
|
||||
published_by text not null,
|
||||
note text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create unique index if not exists organigram_versions_number_idx
|
||||
on public.organigram_versions (version_number desc);
|
||||
|
||||
-- RLS: admin-only (Default deny, kein anon-Grant) — wie 0048.
|
||||
alter table public.organigram_versions enable row level security;
|
||||
|
||||
-- Service-Role umgeht RLS, braucht auf Self-Host aber explizite Grants.
|
||||
grant select, insert, update, delete on
|
||||
public.organigram_versions
|
||||
to service_role;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- ====================================================================
|
||||
-- 0052_appointment_requests — Terminanfragen (Booking Phase 0)
|
||||
-- ====================================================================
|
||||
-- Native Terminanfrage über die Visitenkarte: Gast schlägt Wunschtermine
|
||||
-- vor → landet als Anfrage beim Mitarbeiter (E-Mail + Portal-Inbox).
|
||||
-- KEINE Kalenderanbindung (das ist Phase 1, Microsoft Graph). Siehe
|
||||
-- docs/booking-modul.md.
|
||||
|
||||
-- Opt-in pro Mitarbeiter: nur wer das aktiviert, zeigt „Termin anfragen".
|
||||
alter table public.employees
|
||||
add column if not exists accept_appointments boolean not null default false;
|
||||
|
||||
comment on column public.employees.accept_appointments is
|
||||
'Wenn true, zeigt die öffentliche Karte ein „Termin anfragen"-Formular.';
|
||||
|
||||
create table if not exists public.appointment_requests (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
employee_id uuid not null references public.employees(id) on delete cascade,
|
||||
guest_name text not null,
|
||||
guest_email text,
|
||||
guest_phone text,
|
||||
company text,
|
||||
subject text,
|
||||
message text,
|
||||
-- Wunschtermine: [{ "date": "2026-07-01", "period": "vormittags" }] (max 3)
|
||||
preferred_slots jsonb not null default '[]'::jsonb,
|
||||
meeting_type text, -- 'vor_ort' | 'telefon' | 'video' | null
|
||||
status text not null default 'new',
|
||||
-- 'new' | 'confirmed' | 'declined' | 'cancelled'
|
||||
consent_given boolean not null default false,
|
||||
source_url text,
|
||||
read_at timestamptz,
|
||||
notes text, -- interne Notiz des Mitarbeiters
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists appointment_requests_employee_idx
|
||||
on public.appointment_requests (employee_id, created_at desc);
|
||||
|
||||
-- RLS: admin-only (Default deny, kein anon-Grant). Öffentliche Inserts
|
||||
-- laufen ausschließlich über die Service-Role-Action (wie card_leads).
|
||||
alter table public.appointment_requests enable row level security;
|
||||
|
||||
grant select, insert, update, delete on
|
||||
public.appointment_requests
|
||||
to service_role;
|
||||
@@ -0,0 +1,83 @@
|
||||
-- ====================================================================
|
||||
-- 0053_booking_calendar — Kalenderbuchung (Booking Phase 1, Microsoft Graph)
|
||||
-- ====================================================================
|
||||
-- Baut auf Phase 0 (0052_appointment_requests) auf. Modell: zentrale
|
||||
-- Azure-AD-App mit *Application Permissions* (IT erteilt EINMAL Admin-
|
||||
-- Consent) + Opt-in pro Mitarbeiter. Kein Pro-MA-OAuth, keine Refresh-
|
||||
-- Token-Speicherung — das App-only-Token kommt per Client-Credentials,
|
||||
-- nur das Client-Secret liegt in der ENV. Siehe docs/booking-modul.md.
|
||||
--
|
||||
-- DSGVO: Der Opt-in-Schalter (calendar_connections.enabled) ist die
|
||||
-- aktive Entscheidung des MA. Zusätzlich beschränkt eine Exchange
|
||||
-- Application Access Policy die App technisch auf eine Sicherheitsgruppe.
|
||||
|
||||
-- ── Pro-MA-Verbindung + Verfügbarkeitsregeln ───────────────────────────
|
||||
create table if not exists public.calendar_connections (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
employee_id uuid not null unique
|
||||
references public.employees(id) on delete cascade,
|
||||
provider text not null default 'microsoft',
|
||||
-- M365-Postfach für die Graph-Calls (UPN/Mailbox, i. d. R. = Dienst-Mail).
|
||||
mailbox_upn text not null,
|
||||
-- Opt-in: nur wenn true, ruft TeamVis Graph für dieses Postfach auf.
|
||||
enabled boolean not null default true,
|
||||
-- Verfügbarkeitsregeln (Slots = Arbeitszeit − Belegt − vergebene Buchungen)
|
||||
timezone text not null default 'Europe/Berlin',
|
||||
slot_minutes integer not null default 30,
|
||||
buffer_minutes integer not null default 0,
|
||||
min_notice_hours integer not null default 24,
|
||||
max_advance_days integer not null default 30,
|
||||
workday_start time not null default '09:00',
|
||||
workday_end time not null default '17:00',
|
||||
-- ISO-Wochentage: 1=Montag … 7=Sonntag
|
||||
workdays integer[] not null default '{1,2,3,4,5}',
|
||||
-- Diagnostik: letzter erfolgreicher „Verbindung testen", letzter Fehler.
|
||||
last_verified_at timestamptz,
|
||||
last_error text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
comment on table public.calendar_connections is
|
||||
'Opt-in pro Mitarbeiter für die Microsoft-Graph-Kalenderbuchung (Phase 1).';
|
||||
|
||||
-- ── Bestätigte Buchungen (echter Kalendereintrag via Graph) ────────────
|
||||
create table if not exists public.bookings (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
employee_id uuid not null
|
||||
references public.employees(id) on delete cascade,
|
||||
appointment_request_id uuid
|
||||
references public.appointment_requests(id) on delete set null,
|
||||
start_at timestamptz not null,
|
||||
end_at timestamptz not null,
|
||||
time_zone text not null default 'Europe/Berlin',
|
||||
guest_name text not null,
|
||||
guest_email text,
|
||||
guest_phone text,
|
||||
company text,
|
||||
subject text,
|
||||
message text,
|
||||
meeting_type text, -- 'vor_ort' | 'telefon' | 'video' | null
|
||||
status text not null default 'confirmed',
|
||||
-- 'confirmed' | 'cancelled'
|
||||
graph_event_id text, -- Outlook-Event-ID (für Storno/Update)
|
||||
ical_uid text,
|
||||
cancel_token text not null, -- Self-Service-Storno durch den Gast
|
||||
consent_given boolean not null default false,
|
||||
source_url text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists bookings_employee_idx
|
||||
on public.bookings (employee_id, start_at);
|
||||
create index if not exists bookings_cancel_token_idx
|
||||
on public.bookings (cancel_token);
|
||||
|
||||
-- ── RLS: admin/service-role only, kein anon-Grant ──────────────────────
|
||||
-- Frei/Belegt-Lookup und Insert laufen ausschließlich über serverseitige
|
||||
-- (rate-limitierte) Service-Role-Actions, wie card_leads/appointment_requests.
|
||||
alter table public.calendar_connections enable row level security;
|
||||
alter table public.bookings enable row level security;
|
||||
|
||||
grant select, insert, update, delete on public.calendar_connections to service_role;
|
||||
grant select, insert, update, delete on public.bookings to service_role;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- ====================================================================
|
||||
-- 0054_booking_ical — Buchung per iCal-Einladung (selbsthoster-tauglich)
|
||||
-- ====================================================================
|
||||
-- Macht die Kalenderbuchung zum Default OHNE externe Einrichtung: MA legt
|
||||
-- Verfügbarkeit fest, Gast bucht echte Slots, beide bekommen eine .ics-
|
||||
-- Termineinladung per SMTP. Frei/Belegt kommt aus der eigenen bookings-
|
||||
-- Tabelle, NICHT aus einem Postfach. Die Microsoft-Graph-Anbindung
|
||||
-- (0053) bleibt als optionaler Modus 'microsoft' liegen. Siehe
|
||||
-- docs/booking-modul.md.
|
||||
|
||||
-- Modus pro MA: 'ical' (Default, kein Setup) oder 'microsoft' (Graph).
|
||||
alter table public.calendar_connections
|
||||
add column if not exists mode text not null default 'ical';
|
||||
|
||||
-- mailbox_upn wird nur im 'microsoft'-Modus gebraucht → optional machen.
|
||||
alter table public.calendar_connections
|
||||
alter column mailbox_upn drop not null;
|
||||
|
||||
comment on column public.calendar_connections.mode is
|
||||
'''ical'' = Buchung per .ics-Einladung (kein externes Setup); ''microsoft'' = Microsoft-Graph (optional).';
|
||||
Reference in New Issue
Block a user