From b87c8ad64ca2bebf2412b887d6d470022804291a Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Mon, 7 Sep 2026 10:43:22 +0200 Subject: [PATCH] Remove Supabase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The database moved to a container of our own; the platform is gone. This takes out what was left of it — and, where the leftovers were load bearing, moves rather than deletes. Moved, not deleted: supabase/migrations/ -> db/migrations/ the schema's source of truth supabase/build-org.ts -> scripts/build-org.ts lib/supabase/types.ts -> lib/types.ts 52 import sites repointed The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`, and simply renaming the schema would have left the runner facing an empty table: it would have called all 67 migrations pending and replayed them against a database that is long since current. So the runner now creates `migrationen.schema_migrations` and, once, copies the old rows across — guarded so a second run does nothing and a fresh database skips it entirely. Only then does migration 20260907100000 drop the old schema. Deleted: the CLI config, the seed, the historical schema/function dumps (nothing read them), scripts/umzug-von-supabase.sh (the move is done), and both Supabase packages plus the CLI. Nothing in the application imported them — the build now succeeds with no environment variables at all, which is the proof. Integration tests: six of them signed in through Supabase Auth and asserted against the anon key and the service role. That model is gone, so the tests were not portable — they are deleted. session-context and employee-status-filter already ran on pg and are untouched; om-reporting is ported to a direct connection because it guards a real risk (the reporting line rule exists twice, once in SQL and once in TypeScript). CI: the integration job started a Supabase stack. It now runs a postgres service, applies deploy/db-init and every migration to an empty database — that was the valuable part, and it still holds — then checks that a second run is a no-op, which is what proves the bookkeeping works. Docs: security-review.md audited a service-role key, a cookie adapter and auth.users, none of which exist. Restating findings about removed components would suggest today's system had been reviewed; it has not. It now records what was removed and says a fresh review is due. data-model.md was already marked obsolete and described the pre-OM schema; azure-migration.md was a plan for a route not taken. Both deleted. Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the packages. Integration tests skip cleanly with no database. Migration SQL and the runner are reviewed but NOT executed: no Docker here, and the old instance no longer resolves. Co-Authored-By: Claude Opus 5 --- .dockerignore | 3 - .github/workflows/ci.yml | 101 +- .github/workflows/deploy.yml | 8 +- .gitignore | 14 +- DEPLOYMENT.md | 58 +- README.md | 63 +- actions/employees.ts | 2 +- app/(app)/employees/page.tsx | 2 +- app/(app)/error.tsx | 2 +- app/(app)/loading.tsx | 2 +- app/api/cron/apply-pending-changes/route.ts | 2 +- app/api/export/employees/route.ts | 2 +- components/audit/AuditDetail.tsx | 2 +- components/employees/AddDependentModal.tsx | 2 +- components/employees/AngehoerigeSection.tsx | 2 +- components/employees/EmployeeDetail.tsx | 2 +- components/employees/HistorieBearbeiten.tsx | 2 +- components/employees/RoleEmploymentFields.tsx | 2 +- .../employees/panels/DatenAendernPanel.tsx | 2 +- components/employees/panels/KarenzPanel.tsx | 2 +- components/employees/panels/PromotePanel.tsx | 2 +- components/employees/panels/RehirePanel.tsx | 2 +- .../employees/panels/TerminatePanel.tsx | 2 +- components/employees/panels/TransferPanel.tsx | 2 +- components/employees/tabs/HistorieTab.tsx | 2 +- components/employees/tabs/NotizenTab.tsx | 2 +- components/employees/tabs/StammdatenTab.tsx | 2 +- components/employees/tabs/VertragTab.tsx | 2 +- components/hire/StepAngehoerige.tsx | 2 +- components/hire/StepNotfallkontakt.tsx | 2 +- components/hire/StepVertrag.tsx | 2 +- components/hire/types.ts | 2 +- components/reports/ReportsPageClient.tsx | 2 +- components/ui/AenderungsTabelle.tsx | 2 +- components/ui/StatusChip.tsx | 2 +- .../20260601000000_initial_schema.sql | 0 ...01000100_nationality_country_free_text.sql | 0 ...0601000200_business_mutation_functions.sql | 0 .../20260601000300_start_karenz_function.sql | 0 ...00400_reorg_undo_history_delete_policy.sql | 0 ...00_change_employee_data_effective_date.sql | 0 .../20260714120000_hr_only_access.sql | 0 .../20260714120050_pending_org_changes.sql | 0 .../20260714120100_salary_deprecation.sql | 0 .../20260714120200_effective_dating_rpcs.sql | 0 .../20260714120300_reorg_undo_append_only.sql | 0 .../20260714120400_performance_indexes.sql | 0 .../20260714120500_default_grants.sql | 0 .../20260714120600_data_integrity_guards.sql | 0 .../20260715120000_split_address_fields.sql | 0 ...716120000_position_validity_and_delete.sql | 0 .../20260718120000_role_employment_fields.sql | 0 .../20260718140000_employee_dependents.sql | 0 .../20260718150000_person_titles.sql | 0 ...0718160000_dependents_effective_dating.sql | 0 .../20260719120000_employee_notes.sql | 0 ...0724120000_employee_assignment_history.sql | 0 .../20260725120000_svnr_validation.sql | 0 .../20260726120000_absence_type.sql | 0 .../20260727120000_sap_om_org_model.sql | 0 .../20260727120100_om_reporting_lines.sql | 0 .../migrations/20260727120200_om_cutover.sql | 0 ...0260727130000_om_cleanup_and_positions.sql | 0 ...0260727140000_pin_function_search_path.sql | 0 .../20260727150000_revoke_definer_execute.sql | 0 ...30120000_app_users_and_session_context.sql | 0 .../20260731090000_app_upsert_user.sql | 0 .../20260803120000_import_sequence_grant.sql | 0 .../20260803140000_audit_changes_detail.sql | 0 .../20260805100000_fix_stale_type_casts.sql | 0 ...05110000_replace_auth_uid_in_functions.sql | 0 ...805120000_fix_hire_employee_precedence.sql | 0 .../20260805130000_update_position.sql | 0 ...805140000_profiles_reference_app_users.sql | 0 ...100000_position_validity_on_assignment.sql | 0 ...260810110000_fix_hire_workdays_default.sql | 0 .../20260810120000_rehire_position_checks.sql | 0 .../20260810130000_fix_rehire_status_cast.sql | 0 ...0811100000_personnel_number_is_entered.sql | 0 ...1110000_emergency_contact_and_car_type.sql | 0 ...11120000_change_data_covers_new_fields.sql | 0 .../20260811130000_hire_takes_new_fields.sql | 0 .../20260811140000_private_email_optional.sql | 0 ...20260813120000_history_carries_changes.sql | 0 .../20260813140000_delete_history_entry.sql | 0 ...20260813160000_revert_in_one_statement.sql | 0 .../20260813180000_update_history_entry.sql | 0 ...0260813200000_history_links_to_pending.sql | 0 .../migrations/20260814100000_no_show.sql | 0 .../20260814120000_kuendigungsschutz.sql | 0 .../20260814140000_teilzeit_gruende.sql | 0 .../20260814160000_teilzeit_art.sql | 0 ...70000_teilzeit_bei_geplanter_rueckkehr.sql | 0 .../20260814180000_karenz_ruecknahme.sql | 0 .../20260814200000_aufenthaltstitel.sql | 0 ...000_eintrittsdatum_und_rueckkehr_regel.sql | 0 ...5120000_geplante_abwesenheit_abbrechen.sql | 0 .../20260816100000_kostenstellen.sql | 0 .../20260817100000_onboarding_checkliste.sql | 0 .../20260818100000_offboarding_checkliste.sql | 0 ...0000_rls_sicherheitsnetz_als_migration.sql | 0 .../20260826120000_anwendungsrolle_rechte.sql | 0 ...260907100000_alte_buchfuehrung_ablegen.sql | 27 + docker-compose.yml | 2 +- docs/azure-migration.md | 148 --- docs/data-model.md | 113 --- docs/datenkatalog.md | 9 +- docs/security-review.md | 151 +-- eslint.config.mjs | 2 +- lib/absence.ts | 4 +- lib/colors.ts | 4 +- lib/dashboard-data.ts | 2 +- lib/db/index.ts | 4 +- lib/db/pool.ts | 4 +- lib/db/rpc.ts | 2 +- lib/db/schema.ts | 11 +- lib/dienstwagen.ts | 2 +- lib/employee-status-filter.ts | 2 +- lib/history.ts | 2 +- lib/notes.ts | 2 +- lib/org.ts | 2 +- lib/report-criteria.ts | 2 +- lib/reports-data.ts | 2 +- lib/reports.ts | 2 +- lib/{supabase => }/types.ts | 79 +- package.json | 9 +- {supabase => scripts}/build-org.ts | 0 scripts/check-schema-types.mjs | 12 +- scripts/migrate.mjs | 69 +- scripts/umzug-von-supabase.sh | 77 -- supabase/config.toml | 33 - supabase/functions.sql | 555 ---------- supabase/functions_2.sql | 29 - supabase/functions_3.sql | 15 - supabase/functions_4.sql | 71 -- supabase/schema.sql | 360 ------- supabase/schema_2.sql | 11 - supabase/seed.ts | 956 ------------------ tests/components/HistorieTab.test.tsx | 2 +- tests/components/TerminatePanel.test.tsx | 2 +- tests/components/VertragTab.test.tsx | 2 +- tests/integration/authorization.test.ts | 95 -- tests/integration/data-integrity.test.ts | 135 --- tests/integration/effective-dating.test.ts | 199 ---- .../employee-status-filter.test.ts | 2 +- tests/integration/helpers.ts | 201 ---- tests/integration/om-reporting.test.ts | 94 +- .../integration/position-assignments.test.ts | 216 ---- tests/integration/positions.test.ts | 209 ---- tests/integration/svnr-validation.test.ts | 140 --- tests/unit/build-org.test.ts | 2 +- tests/unit/db-type-parsers.test.ts | 2 +- tests/unit/history.test.ts | 2 +- tsconfig.json | 3 - vitest.integration.config.ts | 14 +- 155 files changed, 386 insertions(+), 4014 deletions(-) rename {supabase => db}/migrations/20260601000000_initial_schema.sql (100%) rename {supabase => db}/migrations/20260601000100_nationality_country_free_text.sql (100%) rename {supabase => db}/migrations/20260601000200_business_mutation_functions.sql (100%) rename {supabase => db}/migrations/20260601000300_start_karenz_function.sql (100%) rename {supabase => db}/migrations/20260601000400_reorg_undo_history_delete_policy.sql (100%) rename {supabase => db}/migrations/20260601000500_change_employee_data_effective_date.sql (100%) rename {supabase => db}/migrations/20260714120000_hr_only_access.sql (100%) rename {supabase => db}/migrations/20260714120050_pending_org_changes.sql (100%) rename {supabase => db}/migrations/20260714120100_salary_deprecation.sql (100%) rename {supabase => db}/migrations/20260714120200_effective_dating_rpcs.sql (100%) rename {supabase => db}/migrations/20260714120300_reorg_undo_append_only.sql (100%) rename {supabase => db}/migrations/20260714120400_performance_indexes.sql (100%) rename {supabase => db}/migrations/20260714120500_default_grants.sql (100%) rename {supabase => db}/migrations/20260714120600_data_integrity_guards.sql (100%) rename {supabase => db}/migrations/20260715120000_split_address_fields.sql (100%) rename {supabase => db}/migrations/20260716120000_position_validity_and_delete.sql (100%) rename {supabase => db}/migrations/20260718120000_role_employment_fields.sql (100%) rename {supabase => db}/migrations/20260718140000_employee_dependents.sql (100%) rename {supabase => db}/migrations/20260718150000_person_titles.sql (100%) rename {supabase => db}/migrations/20260718160000_dependents_effective_dating.sql (100%) rename {supabase => db}/migrations/20260719120000_employee_notes.sql (100%) rename {supabase => db}/migrations/20260724120000_employee_assignment_history.sql (100%) rename {supabase => db}/migrations/20260725120000_svnr_validation.sql (100%) rename {supabase => db}/migrations/20260726120000_absence_type.sql (100%) rename {supabase => db}/migrations/20260727120000_sap_om_org_model.sql (100%) rename {supabase => db}/migrations/20260727120100_om_reporting_lines.sql (100%) rename {supabase => db}/migrations/20260727120200_om_cutover.sql (100%) rename {supabase => db}/migrations/20260727130000_om_cleanup_and_positions.sql (100%) rename {supabase => db}/migrations/20260727140000_pin_function_search_path.sql (100%) rename {supabase => db}/migrations/20260727150000_revoke_definer_execute.sql (100%) rename {supabase => db}/migrations/20260730120000_app_users_and_session_context.sql (100%) rename {supabase => db}/migrations/20260731090000_app_upsert_user.sql (100%) rename {supabase => db}/migrations/20260803120000_import_sequence_grant.sql (100%) rename {supabase => db}/migrations/20260803140000_audit_changes_detail.sql (100%) rename {supabase => db}/migrations/20260805100000_fix_stale_type_casts.sql (100%) rename {supabase => db}/migrations/20260805110000_replace_auth_uid_in_functions.sql (100%) rename {supabase => db}/migrations/20260805120000_fix_hire_employee_precedence.sql (100%) rename {supabase => db}/migrations/20260805130000_update_position.sql (100%) rename {supabase => db}/migrations/20260805140000_profiles_reference_app_users.sql (100%) rename {supabase => db}/migrations/20260810100000_position_validity_on_assignment.sql (100%) rename {supabase => db}/migrations/20260810110000_fix_hire_workdays_default.sql (100%) rename {supabase => db}/migrations/20260810120000_rehire_position_checks.sql (100%) rename {supabase => db}/migrations/20260810130000_fix_rehire_status_cast.sql (100%) rename {supabase => db}/migrations/20260811100000_personnel_number_is_entered.sql (100%) rename {supabase => db}/migrations/20260811110000_emergency_contact_and_car_type.sql (100%) rename {supabase => db}/migrations/20260811120000_change_data_covers_new_fields.sql (100%) rename {supabase => db}/migrations/20260811130000_hire_takes_new_fields.sql (100%) rename {supabase => db}/migrations/20260811140000_private_email_optional.sql (100%) rename {supabase => db}/migrations/20260813120000_history_carries_changes.sql (100%) rename {supabase => db}/migrations/20260813140000_delete_history_entry.sql (100%) rename {supabase => db}/migrations/20260813160000_revert_in_one_statement.sql (100%) rename {supabase => db}/migrations/20260813180000_update_history_entry.sql (100%) rename {supabase => db}/migrations/20260813200000_history_links_to_pending.sql (100%) rename {supabase => db}/migrations/20260814100000_no_show.sql (100%) rename {supabase => db}/migrations/20260814120000_kuendigungsschutz.sql (100%) rename {supabase => db}/migrations/20260814140000_teilzeit_gruende.sql (100%) rename {supabase => db}/migrations/20260814160000_teilzeit_art.sql (100%) rename {supabase => db}/migrations/20260814170000_teilzeit_bei_geplanter_rueckkehr.sql (100%) rename {supabase => db}/migrations/20260814180000_karenz_ruecknahme.sql (100%) rename {supabase => db}/migrations/20260814200000_aufenthaltstitel.sql (100%) rename {supabase => db}/migrations/20260815100000_eintrittsdatum_und_rueckkehr_regel.sql (100%) rename {supabase => db}/migrations/20260815120000_geplante_abwesenheit_abbrechen.sql (100%) rename {supabase => db}/migrations/20260816100000_kostenstellen.sql (100%) rename {supabase => db}/migrations/20260817100000_onboarding_checkliste.sql (100%) rename {supabase => db}/migrations/20260818100000_offboarding_checkliste.sql (100%) rename {supabase => db}/migrations/20260819100000_rls_sicherheitsnetz_als_migration.sql (100%) rename {supabase => db}/migrations/20260826120000_anwendungsrolle_rechte.sql (100%) create mode 100644 db/migrations/20260907100000_alte_buchfuehrung_ablegen.sql delete mode 100644 docs/azure-migration.md delete mode 100644 docs/data-model.md rename lib/{supabase => }/types.ts (91%) rename {supabase => scripts}/build-org.ts (100%) delete mode 100644 scripts/umzug-von-supabase.sh delete mode 100644 supabase/config.toml delete mode 100644 supabase/functions.sql delete mode 100644 supabase/functions_2.sql delete mode 100644 supabase/functions_3.sql delete mode 100644 supabase/functions_4.sql delete mode 100644 supabase/schema.sql delete mode 100644 supabase/schema_2.sql delete mode 100644 supabase/seed.ts delete mode 100644 tests/integration/authorization.test.ts delete mode 100644 tests/integration/data-integrity.test.ts delete mode 100644 tests/integration/effective-dating.test.ts delete mode 100644 tests/integration/helpers.ts delete mode 100644 tests/integration/position-assignments.test.ts delete mode 100644 tests/integration/positions.test.ts delete mode 100644 tests/integration/svnr-validation.test.ts diff --git a/.dockerignore b/.dockerignore index 7cd7c8c..31f1702 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,6 +15,3 @@ npm-debug.log* *.tsbuildinfo .scratch_* .scratch_shots -supabase/.branches -supabase/.temp -supabase/snippets diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d0041e..537a8e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,25 +29,45 @@ jobs: - name: Typecheck run: npm run typecheck - # Catches the drift that `tsc` cannot: lib/supabase/types.ts is - # hand-written, so a migration adding a column leaves it silently stale. + # Fängt die Abweichung, die `tsc` nicht sehen kann: lib/types.ts wird von + # Hand gepflegt, eine Migration mit einer neuen Spalte lässt sie also + # stillschweigend veralten. - name: Schema/Typen-Abgleich run: npm run types:check - name: Unit- und Komponententests run: npm test + # Ohne Umgebungsvariablen: seit dem Wegfall der Browser-Anbindung wird + # zur Bauzeit nichts mehr aus der Umgebung gelesen, und das Abbild ist + # für jede Umgebung dasselbe. - name: Build run: npm run build - env: - # Read at module scope by the Supabase browser client, so the build - # needs them present — never the real project's values. - NEXT_PUBLIC_SUPABASE_URL: http://127.0.0.1:54321 - NEXT_PUBLIC_SUPABASE_ANON_KEY: build-time-placeholder - integration: - name: Integrationstests (echtes Postgres) + migrationen: + name: Migrationen auf leerer Datenbank runs-on: ubuntu-latest + + # Derselbe Stand wie im Betrieb. Ein eigener Dienst statt einer fremden + # Plattform — die Datenbank gehört seit dem Umzug zum Projekt. + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_PASSWORD: ci + POSTGRES_DB: alpenwerk + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + + env: + MIGRATE_DATABASE_URL: postgresql://postgres:ci@127.0.0.1:5432/alpenwerk + DATABASE_SSL: "false" + steps: - uses: actions/checkout@v4 @@ -58,40 +78,37 @@ jobs: - run: npm ci - - uses: supabase/setup-cli@v1 - with: - version: latest - - # Applies every migration to a fresh database — which also means a - # migration that cannot be replayed from scratch fails here rather than - # on a restore or a new environment. - - name: Supabase starten - run: supabase start - - # `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY / DB_URL; the app - # expects them under its own names. DATABASE_URL ist der direkte - # Postgres-Zugang — den braucht die neue Zugriffsschicht (lib/db) und - # vor allem der Nachweis zum Sitzungskontext. - - name: Testumgebung schreiben + # Was das Schema von der Umgebung erwartet: die Anwendungsrolle ohne + # BYPASSRLS, zwei Erweiterungen und die Attrappe des `auth`-Schemas, ohne + # die sich die Migrationen von Juni 2026 nicht abspielen lassen. + - name: Rollen, Erweiterungen, auth-Attrappe + env: + PGPASSWORD: ci + APP_DB_PASSWORD: ci-anwendungsrolle run: | - supabase status -o env \ - --override-name api.url=NEXT_PUBLIC_SUPABASE_URL \ - --override-name auth.anon_key=NEXT_PUBLIC_SUPABASE_ANON_KEY \ - --override-name auth.service_role_key=SUPABASE_SERVICE_ROLE_KEY \ - --override-name db.url=DATABASE_URL \ - | grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY|DATABASE_URL)=' \ - | tr -d '"' > .env.test.local - # Lokal läuft Postgres ohne TLS; ohne das versucht `pg` es trotzdem. - echo "DATABASE_SSL=false" >> .env.test.local - grep -q '^DATABASE_URL=' .env.test.local \ - || { echo "DATABASE_URL wurde nicht geschrieben — der Sitzungskontext-Nachweis liefe ins Leere."; exit 1; } + for f in deploy/db-init/*.sql; do + echo "── $f" + psql -v ON_ERROR_STOP=1 -h 127.0.0.1 -U postgres -d alpenwerk -f "$f" + done - - name: Seed - run: node --env-file=.env.test.local supabase/seed.ts + # Der eigentliche Zweck dieses Jobs: jede Migration muss sich auf einer + # leeren Datenbank abspielen lassen. Eine, die das nicht kann, fällt hier + # auf — und nicht beim Wiederherstellen einer Sicherung oder beim + # Aufsetzen einer neuen Umgebung. + - name: Migrationen einspielen + run: node scripts/migrate.mjs - - name: Integrationstests - run: npm run test:integration + # Zweiter Lauf: die Buchführung muss greifen. Meldet er etwas anderes als + # „nichts anzuwenden", verbucht der Läufer nicht richtig — und ein + # Ausrollen spielte Migrationen doppelt ein. + - name: Zweiter Lauf ist ein Nichts + run: | + ausgabe=$(node scripts/migrate.mjs) + echo "$ausgabe" + echo "$ausgabe" | grep -q "^Nichts anzuwenden" \ + || { echo "Der zweite Lauf wollte erneut anwenden — die Buchführung greift nicht."; exit 1; } - - name: Supabase-Logs bei Fehlschlag - if: failure() - run: supabase status && docker ps -a + # Die Integrationstests brauchen einen befüllten Bestand; der Seed lag + # in der abgelösten Umgebung und ist noch nicht nachgezogen (siehe + # README, offene Punkte). Bis dahin laufen sie gegen eine erreichbare + # Datenbank von Hand, nicht hier. diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f36de29..eb25c20 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -96,7 +96,7 @@ jobs: - name: .env schreiben env: DATABASE_URL: ${{ secrets.DATABASE_URL }} - SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }} + MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_URL }} AUTH_SECRET: ${{ secrets.AUTH_SECRET }} AUTH_URL: ${{ secrets.AUTH_URL }} AUTH_MICROSOFT_ENTRA_ID_ID: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_ID }} @@ -106,7 +106,7 @@ jobs: run: | set -euo pipefail fehlend="" - for name in DATABASE_URL SUPABASE_DB_URL AUTH_SECRET AUTH_URL \ + for name in DATABASE_URL MIGRATE_DATABASE_URL AUTH_SECRET AUTH_URL \ AUTH_MICROSOFT_ENTRA_ID_ID AUTH_MICROSOFT_ENTRA_ID_SECRET \ AUTH_MICROSOFT_ENTRA_ID_ISSUER CRON_SECRET; do eval "wert=\${$name:-}" @@ -138,12 +138,12 @@ jobs: # wenn danach etwas schiefgeht. - name: Ausstehende Migrationen zeigen env: - SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }} + MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_URL }} run: node scripts/migrate.mjs --dry-run - name: Migrationen anwenden env: - SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }} + MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_URL }} run: node scripts/migrate.mjs # ── Container ─────────────────────────────────────────────────── diff --git a/.gitignore b/.gitignore index 2281ef2..5bdf4f8 100644 --- a/.gitignore +++ b/.gitignore @@ -38,17 +38,9 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -# supabase CLI local dev (generated, project-machine-specific) -/supabase/.branches -/supabase/.temp -/supabase/snippets -# scratch output of `npm run types:generate`, for comparing against the -# hand-written lib/supabase/types.ts — never itself imported -/lib/supabase/types.generated.ts - -# local scratch scripts/screenshots (ad-hoc verification against a real or -# seeded DB — can carry HR data or use SUPABASE_SERVICE_ROLE_KEY; never commit) +# Kladden und Bildschirmfotos aus der Handprüfung — können Personendaten +# oder Zugangsdaten enthalten; nie committen. .scratch_* /.scratch_shots/ @@ -56,5 +48,3 @@ next-env.d.ts .backups/ # Datenabzuege aus dem Umzug – enthalten Personendaten -supabase-daten-*.sql -supabase-voll-*.sql diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 6793be1..4c4b6ed 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -15,12 +15,10 @@ eine `profiles`-Zeile braucht (siehe [docs/entra-sso.md](docs/entra-sso.md)). - **Containerisiert:** die Next.js-App (`Dockerfile`) **und die Datenbank** (Dienst `db`, `postgres:17-alpine`). Beide zusammen in `docker-compose.yml`; die Daten liegen im benannten Volume `db-daten`. -- **Nicht mehr Supabase.** Die Anwendung sprach ohnehin unmittelbar mit - PostgreSQL — Supabase war nur der Betreiber. Was die Plattform beisteuerte, - bringt jetzt `deploy/db-init/` mit: die Anwendungsrolle ohne BYPASSRLS, die - zwei benutzten Erweiterungen und eine Attrappe des `auth`-Schemas, die nur - die alten Migrationen von Juni brauchen. Der Umzug steht in - [Abschnitt 3a](#3a-umzug-von-supabase). +- **Die Datenbank gehört zum Projekt.** Was eine gehostete Plattform sonst + beisteuert, bringt `deploy/db-init/` mit: die Anwendungsrolle ohne + BYPASSRLS, die zwei benutzten Erweiterungen und eine Attrappe des + `auth`-Schemas, die nur die Migrationen von Juni 2026 brauchen. - Läuft die Datenbank woanders (Azure Flexible Server, RDS, eigenes Blech), genügt es, `DATABASE_URL` dorthin zeigen zu lassen und den `db`-Dienst nicht zu starten. Die Anwendung merkt keinen Unterschied. @@ -152,41 +150,6 @@ docker compose up -d --build `migrate` steht im Profil `tools` und läuft bei `docker compose up` nicht mit. Auf dem Host wird dafür weder Node noch psql gebraucht — nur Docker. -## 3a. Umzug von Supabase - -Einmalig, wenn der Bestand noch bei Supabase liegt: - -```bash -SUPABASE_DB_URL='postgresql://postgres:@.supabase.com:5432/postgres' \ - ./scripts/umzug-von-supabase.sh -``` - -Was das Skript tut und warum: - -1. **Schema aus den Migrationen**, nicht aus einem Abzug. Nachgewiesen ist, - dass alle Migrationen auf einer leeren Datenbank durchlaufen und dabei - Spalte für Spalte, Index für Index, Policy für Policy dasselbe ergeben wie - die gewachsene Produktion. Der Weg hat zwei Vorteile: die Buchführung - stimmt danach von selbst, und Supabase-eigene Rechte und Eigentümer kommen - gar nicht erst mit. -2. **Nur die Daten** werden abgezogen (`pg_dump --data-only - --disable-triggers`). Ohne `--disable-triggers` stolpert jede - Fremdschlüsselprüfung über die Ladereihenfolge. RLS steht nicht im Weg — - keine der 19 Tabellen hat `FORCE ROW LEVEL SECURITY`, der Eigentümer - schreibt also durch. -3. Am Ende werden die Zeilenzahlen ausgegeben. **Mit denen bei Supabase - vergleichen**, bevor irgendetwas abgeschaltet wird. - -Der Abzug bleibt als Datei liegen. Erst löschen, wenn die Anwendung gegen die -neue Datenbank nachweislich läuft. - -Danach in `.env`: - -``` -DATABASE_URL=postgresql://alpenwerk_app:@db:5432/alpenwerk -DATABASE_SSL=false -``` - ### Was am `auth`-Schema übrigbleibt `deploy/db-init/01-auth-attrappe.sql` legt ein leeres `auth`-Schema an. Es wird @@ -283,8 +246,8 @@ und löscht sie danach wieder. | Secret | Anmerkung | |---|---| -| `DATABASE_URL` | Transaktions-Pooler (Supabase: 6543) — den benutzt die Anwendung | -| `SUPABASE_DB_URL` | **Direkter** Zugang (Supabase: 5432) — nur für die Migrationen | +| `DATABASE_URL` | Verbindung der Anwendungsrolle — die benutzt die Anwendung | +| `MIGRATE_DATABASE_URL` | Zugang als Verwalter — nur für die Migrationen | | `AUTH_SECRET` | | | `AUTH_URL` | z. B. `https://hr.elycon.solutions` | | `AUTH_MICROSOFT_ENTRA_ID_ID` | | @@ -332,9 +295,9 @@ node --env-file=.env scripts/migrate.mjs --dry-run # was stünde an node --env-file=.env scripts/migrate.mjs # anwenden ``` -Buch geführt wird in `supabase_migrations.schema_migrations`, derselben Tabelle -in derselben Form, die die Supabase-CLI benutzt — `supabase db push` von der -Arbeitsstation bleibt damit möglich und überspringt, was hier schon lief. +Buch geführt wird in `migrationen.schema_migrations`: eine Zeile je +angewendeter Datei, mit ihrem Text. Der Läufer wendet nur an, was dort +fehlt. > **Einmalig, im August 2026 bereits erledigt:** Die Migrationen wurden bis > dahin von Hand eingespielt, die Buchführungstabelle existierte gar nicht. Ein @@ -409,9 +372,6 @@ Single-Instance-Compose-Konfiguration ist das nicht nötig. - **Cron läuft nicht:** `docker compose logs cron` – prüft, ob `/etc/crontabs/root` korrekt geschrieben wurde und ob `CRON_SECRET` in `.env` gesetzt ist (leer/fehlend führt serverseitig zu `401`). -- **`max clients reached in session mode`:** der Verbindungsstring zeigt auf - den Sitzungs-Modus des Poolers. Auf den Transaktions-Modus wechseln (bei - Supabase Port 6543). - **Healthcheck rot:** `docker compose logs app` – meist `DATABASE_URL` fehlend oder nicht erreichbar. Der Pool baut die Verbindung erst beim ersten Zugriff auf, der Fehler steht deshalb im Log der Anfrage, nicht im diff --git a/README.md b/README.md index e639917..d597819 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,13 @@ auf explizit aktivierte HR-Benutzer:innen beschränkt (siehe `node_modules/next/dist/docs/` konsultieren; sie sind maßgeblich, ältere Anleitungen im Netz beschreiben teils überholte APIs. - React 19, TypeScript -- Supabase (Postgres, Auth, RLS) — Datenhaltung liegt vollständig in - Supabase, nicht im Next.js-Prozess. +- PostgreSQL, angesprochen über Kysely und `pg` — die Datenhaltung liegt in + der Datenbank, nicht im Next.js-Prozess. Der Zugriff läuft ausschliesslich + über `withUser()` (`lib/db/`), das den Sitzungskontext setzt. +- Auth.js gegen Microsoft Entra ID — keine eigenen Passwörter. - Tailwind CSS v4 - Vitest — Unit- (Node), Komponenten- (jsdom) und Integrationstests - (gegen ein lokales Supabase) + (gegen eine erreichbare Datenbank) ## Setup @@ -38,14 +40,14 @@ cp .env.example .env.local # Werte eintragen, siehe unten npm run dev ``` -Für lokale Supabase-Entwicklung (statt gegen ein Cloud-Projekt): +Für eine lokale Datenbank genügt der Container aus `docker-compose.yml`: ```bash -supabase start # startet lokalen Postgres/Auth/Studio-Stack +docker compose up -d db +docker compose run --rm migrate # spielt db/migrations/ ein ``` -`supabase/config.toml` und `.env.test.local` sind bereits auf die -Standard-Ports der lokalen Supabase-CLI abgestimmt. +Danach zeigt `DATABASE_URL` in `.env.local` auf diese Datenbank. ## Umgebungsvariablen @@ -77,8 +79,9 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut, | `npm run lint` | ESLint (`eslint-config-next`, Flat Config) | | `npm run typecheck` | `tsc --noEmit` | | `npm run test` | Vitest, Unit-Tests (`tests/unit/**`) | -| `npm run test:integration` | Vitest gegen eine echte (lokale) Supabase-Instanz — braucht `supabase start` und `.env.test.local` | +| `npm run test:integration` | Vitest gegen eine erreichbare Datenbank; überspringt sich ohne `DATABASE_URL` | | `npm run test:e2e` | Playwright | +| `npm run migrate` | Ausstehende Migrationen einspielen (`--dry-run`, `--baseline`) | | `npm run check` | lint + typecheck + test + build in Folge | ## Sicherheitsprinzipien @@ -115,23 +118,24 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut, - Fehlt `CRON_SECRET` oder stimmt der Header nicht, antwortet die Route mit `401` (nicht `500` — bewusst, siehe `tests/unit/security.test.ts`). -## Supabase-Hinweise +## Datenbank -- Schema-Quelle der Wahrheit: `supabase/migrations/`. Menschlich lesbare - Fassung, aus der laufenden Datenbank erzeugt: +- Schema-Quelle der Wahrheit: `db/migrations/`. Menschlich lesbare Fassung, + aus der laufenden Datenbank erzeugt: [`docs/datenkatalog.md`](docs/datenkatalog.md). -- Migrationen einspielen: `supabase db push` (gegen das verlinkte Projekt) - bzw. `supabase start` + automatische Anwendung für lokale Entwicklung. -- `supabase/seed.ts` und `.env.test.local` sind nur für lokale - Entwicklung/Tests gedacht, nie für ein Produktivprojekt verwenden. +- Migrationen einspielen: `npm run migrate`. Der Läufer wendet nur die + fehlenden Dateien an, jede in ihrer eigenen Transaktion, und führt darüber + Buch in `migrationen.schema_migrations`. +- `lib/types.ts` wird von Hand gepflegt. `npm run types:check` hält sie + Spalte für Spalte gegen die Migrationen. ## Testing - `npm run test` — schnell, keine externen Abhängigkeiten, läuft in CI. -- `npm run test:integration` — braucht eine laufende lokale Supabase-Instanz - (`supabase start`) und `.env.test.local`; prüft RLS-Verhalten end-to-end - (siehe `tests/integration/authorization.test.ts` für das HR-Only-Zugriffs- - modell). +- `npm run test:integration` — braucht eine erreichbare Datenbank + (`DATABASE_URL`). Geprüft werden Regeln, die es zweimal gibt: einmal als + SQL, einmal als TypeScript. Ohne `DATABASE_URL` überspringen sich die + Dateien, statt mit einem Verbindungsfehler abzubrechen. - `npm run test:e2e` — Playwright gegen einen laufenden Dev-/Preview-Server. ## Deployment @@ -141,15 +145,18 @@ Reverse-Proxy/TLS, der nächtliche Lauf, Migrationen und Updates. ## Known TODOs vor Produktivbetrieb -- **Content-Security-Policy fehlt noch** (`next.config.ts` setzt bewusst - keine CSP — Skript-/Style-/Connect-Quellen sind noch nicht vollständig - inventarisiert; ungeprüft geraten zu setzen riskiert, Hydration oder den - Supabase-Client stillschweigend zu brechen). -- **Lokale Scratch-Artefakte** (`.scratch_*`, `.scratch_shots/`) enthalten - Screenshots/Hilfsskripte aus einer früheren manuellen Verifikation und - liegen noch im Arbeitsverzeichnis. Sie sind jetzt über `.gitignore` - ausgeschlossen; vor einem Produktiv-Handover sollten sie durchgesehen und - bei Bedarf gelöscht werden. +- **Content-Security-Policy läuft im Nur-Bericht-Modus** (`next.config.ts`). + Erzwungen wird sie erst, wenn die Meldungen sauber sind — eine geratene, + erzwungene Richtlinie blendet die Anwendung für alle aus. +- **Seed für die Integrationstests fehlt.** Er lag in der abgelösten + Umgebung. Zwei der drei Integrationstests vergleichen Regeln über den + gesamten Bestand und brauchen dafür Daten; bis der Seed nachgezogen ist, + laufen sie nur gegen eine bereits befüllte Datenbank, nicht in der CI. +- **Sicherheitsprüfung des heutigen Aufbaus steht aus** — siehe + [`docs/security-review.md`](docs/security-review.md). +- **Bildschirmfotos unter `.scratch_shots/`** stammen aus einer früheren + Handprüfung. Sie sind über `.gitignore` ausgeschlossen; vor der Übergabe + durchsehen und löschen. - **Kein granulareres Rollenmodell** — aktuell HR-only (alles-oder-nichts). Falls z. B. eine reine Lese-Rolle künftig gebraucht wird, gehört die Erweiterung in eine neue Migration (`is_hr_user()`/RLS-Policies), nicht in diff --git a/actions/employees.ts b/actions/employees.ts index 628acbe..4bac77b 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -6,7 +6,7 @@ import { withUser } from "@/lib/db"; import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc"; import { OFFBOARDING_PUNKTE } from "@/lib/offboarding"; import { ONBOARDING_PUNKTE } from "@/lib/onboarding"; -import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types"; +import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/types"; async function callRpc(fn: MutationFn, payload: Record, revalidate: string[]): Promise { const result = await runMutation(await currentUserId(), fn, payload); diff --git a/app/(app)/employees/page.tsx b/app/(app)/employees/page.tsx index eb691b9..fdf7bd4 100644 --- a/app/(app)/employees/page.tsx +++ b/app/(app)/employees/page.tsx @@ -14,7 +14,7 @@ import { derivedStatusFilter } from "@/lib/employee-status-filter"; import { fmtDate, fmtName, todayIso } from "@/lib/format"; import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf, type OrgEb } from "@/lib/org"; import { loadPlacements } from "@/lib/placement"; -import type { EmploymentStatus } from "@/lib/supabase/types"; +import type { EmploymentStatus } from "@/lib/types"; const PAGE_SIZE = 15; diff --git a/app/(app)/error.tsx b/app/(app)/error.tsx index 8de43ca..034fa8e 100644 --- a/app/(app)/error.tsx +++ b/app/(app)/error.tsx @@ -8,7 +8,7 @@ import { Button, LINK_BUTTON_CLASS } from "@/components/ui/Button"; // Without this file a failed render drops the user on Next.js's own error // screen — no navigation, no way back, and in production just "a client-side // exception occurred". `reset()` re-renders the segment, which is enough for -// the common case of a transient Supabase timeout. +// the common case of a transient database timeout. export default function AppError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { useEffect(() => { console.error("Route error:", error); diff --git a/app/(app)/loading.tsx b/app/(app)/loading.tsx index e71724a..680f323 100644 --- a/app/(app)/loading.tsx +++ b/app/(app)/loading.tsx @@ -1,5 +1,5 @@ // Every page in this group is server-rendered per request (they all read -// from Supabase), so without this the browser sits on the previous page with +// from the database), so without this the browser sits on the previous page with // no feedback until the server answers — on the employee list, long enough // to look broken. export default function Loading() { diff --git a/app/api/cron/apply-pending-changes/route.ts b/app/api/cron/apply-pending-changes/route.ts index 34f241f..42db1e3 100644 --- a/app/api/cron/apply-pending-changes/route.ts +++ b/app/api/cron/apply-pending-changes/route.ts @@ -4,7 +4,7 @@ import { callFunction } from "@/lib/db/rpc"; // Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten // ändern with a future "Wirksam ab" date) once their date has arrived — see -// apply_due_pending_changes() in supabase/migrations. +// apply_due_pending_changes() in db/migrations. // // Gerufen wird das vom `cron`-Dienst aus docker-compose.yml, täglich um 03:00. // Nicht im Namen einer HR-Person: es gibt keine angemeldete Sitzung, deshalb diff --git a/app/api/export/employees/route.ts b/app/api/export/employees/route.ts index 4ceab50..5570d3c 100644 --- a/app/api/export/employees/route.ts +++ b/app/api/export/employees/route.ts @@ -9,7 +9,7 @@ import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } f import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data"; import { requireHrUser } from "@/lib/auth/require-hr"; import { withUser } from "@/lib/db"; -import type { Database, Weekday } from "@/lib/supabase/types"; +import type { Database, Weekday } from "@/lib/types"; // Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt // über die Planstelle und die abgeleitete Berichtslinie. diff --git a/components/audit/AuditDetail.tsx b/components/audit/AuditDetail.tsx index 9a66b54..4ed2198 100644 --- a/components/audit/AuditDetail.tsx +++ b/components/audit/AuditDetail.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle"; import { SlideOver } from "@/components/ui/SlideOver"; import { actionBadgeStyle } from "@/lib/colors"; -import type { AuditChange } from "@/lib/supabase/types"; +import type { AuditChange } from "@/lib/types"; // Eine Protokollzeile zum Aufklappen. // diff --git a/components/employees/AddDependentModal.tsx b/components/employees/AddDependentModal.tsx index 88cfe2b..f34fcae 100644 --- a/components/employees/AddDependentModal.tsx +++ b/components/employees/AddDependentModal.tsx @@ -8,7 +8,7 @@ import { SelectField, TextField } from "@/components/ui/Field"; import { Modal } from "@/components/ui/Modal"; import { useToast } from "@/components/ui/Toast"; import { todayIso } from "@/lib/format"; -import type { RelationshipType } from "@/lib/supabase/types"; +import type { RelationshipType } from "@/lib/types"; const RELATIONSHIPS: RelationshipType[] = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"]; diff --git a/components/employees/AngehoerigeSection.tsx b/components/employees/AngehoerigeSection.tsx index 1bfdccc..0eaccf4 100644 --- a/components/employees/AngehoerigeSection.tsx +++ b/components/employees/AngehoerigeSection.tsx @@ -7,7 +7,7 @@ import { deleteEmployeeDependent } from "@/actions/employees"; import { Button } from "@/components/ui/Button"; import { useToast } from "@/components/ui/Toast"; import { fmtDate, todayIso } from "@/lib/format"; -import type { Database } from "@/lib/supabase/types"; +import type { Database } from "@/lib/types"; import { AddDependentModal } from "./AddDependentModal"; type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"]; diff --git a/components/employees/EmployeeDetail.tsx b/components/employees/EmployeeDetail.tsx index 81c8c6c..6c4bd83 100644 --- a/components/employees/EmployeeDetail.tsx +++ b/components/employees/EmployeeDetail.tsx @@ -11,7 +11,7 @@ import { fmtFullName, tenure } from "@/lib/format"; import { fortschritt as fortschrittOffboarding, gehoertOffboarding } from "@/lib/offboarding"; import { fortschritt as fortschrittOnboarding } from "@/lib/onboarding"; import type { OpenPositionResolved } from "@/lib/positions"; -import type { Database } from "@/lib/supabase/types"; +import type { Database } from "@/lib/types"; import { DatenAendernPanel } from "./panels/DatenAendernPanel"; import { KarenzPanel } from "./panels/KarenzPanel"; import { PromotePanel } from "./panels/PromotePanel"; diff --git a/components/employees/HistorieBearbeiten.tsx b/components/employees/HistorieBearbeiten.tsx index f6a0a74..9d59f99 100644 --- a/components/employees/HistorieBearbeiten.tsx +++ b/components/employees/HistorieBearbeiten.tsx @@ -9,7 +9,7 @@ import { TextField } from "@/components/ui/Field"; import { Modal } from "@/components/ui/Modal"; import { useToast } from "@/components/ui/Toast"; import { fmtDate } from "@/lib/format"; -import type { AuditChange } from "@/lib/supabase/types"; +import type { AuditChange } from "@/lib/types"; // Berichtigen, nicht neu erfassen. // diff --git a/components/employees/RoleEmploymentFields.tsx b/components/employees/RoleEmploymentFields.tsx index f81ea8b..8270c9f 100644 --- a/components/employees/RoleEmploymentFields.tsx +++ b/components/employees/RoleEmploymentFields.tsx @@ -1,7 +1,7 @@ "use client"; import { SelectField, TextField } from "@/components/ui/Field"; -import type { CollectiveAgreement, DienstwagenArt, Weekday, WorkerType } from "@/lib/supabase/types"; +import type { CollectiveAgreement, DienstwagenArt, Weekday, WorkerType } from "@/lib/types"; const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; diff --git a/components/employees/panels/DatenAendernPanel.tsx b/components/employees/panels/DatenAendernPanel.tsx index 4086484..1fdfb4a 100644 --- a/components/employees/panels/DatenAendernPanel.tsx +++ b/components/employees/panels/DatenAendernPanel.tsx @@ -16,7 +16,7 @@ import { brauchtAufenthaltstitel, UN_COUNTRIES } from "@/lib/countries"; import { STUNDEN_GRUENDE } from "@/lib/absence"; import { fmtFullName, todayIso } from "@/lib/format"; import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr"; -import { EMERGENCY_RELATIONS, type ContractType, type Database, type EmploymentType, type GenderType } from "@/lib/supabase/types"; +import { EMERGENCY_RELATIONS, type ContractType, type Database, type EmploymentType, type GenderType } from "@/lib/types"; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"]; diff --git a/components/employees/panels/KarenzPanel.tsx b/components/employees/panels/KarenzPanel.tsx index 478842c..ac2e112 100644 --- a/components/employees/panels/KarenzPanel.tsx +++ b/components/employees/panels/KarenzPanel.tsx @@ -10,7 +10,7 @@ import { SlideOver } from "@/components/ui/SlideOver"; import { useToast } from "@/components/ui/Toast"; import { ABSENCE_TYPES, absenceLabel, RUECKKEHR_GRUENDE } from "@/lib/absence"; import { fmtDate, fmtName } from "@/lib/format"; -import type { Database } from "@/lib/supabase/types"; +import type { Database } from "@/lib/types"; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; type Mode = "adjust" | "return"; diff --git a/components/employees/panels/PromotePanel.tsx b/components/employees/panels/PromotePanel.tsx index 67935c3..5ca00ad 100644 --- a/components/employees/panels/PromotePanel.tsx +++ b/components/employees/panels/PromotePanel.tsx @@ -7,7 +7,7 @@ import { Button } from "@/components/ui/Button"; import { SelectField, TextField } from "@/components/ui/Field"; import { SlideOver } from "@/components/ui/SlideOver"; import { useToast } from "@/components/ui/Toast"; -import type { Database, PaygradeType } from "@/lib/supabase/types"; +import type { Database, PaygradeType } from "@/lib/types"; import { fmtName } from "@/lib/format"; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; diff --git a/components/employees/panels/RehirePanel.tsx b/components/employees/panels/RehirePanel.tsx index 683923c..4e865a0 100644 --- a/components/employees/panels/RehirePanel.tsx +++ b/components/employees/panels/RehirePanel.tsx @@ -9,7 +9,7 @@ import { SlideOver } from "@/components/ui/SlideOver"; import { useToast } from "@/components/ui/Toast"; import { fmtDate, fmtName } from "@/lib/format"; import type { OpenPositionResolved } from "@/lib/positions"; -import type { Database } from "@/lib/supabase/types"; +import type { Database } from "@/lib/types"; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; diff --git a/components/employees/panels/TerminatePanel.tsx b/components/employees/panels/TerminatePanel.tsx index 17c90a6..de17afd 100644 --- a/components/employees/panels/TerminatePanel.tsx +++ b/components/employees/panels/TerminatePanel.tsx @@ -7,7 +7,7 @@ import { Button } from "@/components/ui/Button"; import { SelectField, TextField, TextareaField } from "@/components/ui/Field"; import { SlideOver } from "@/components/ui/SlideOver"; import { useToast } from "@/components/ui/Toast"; -import type { Database } from "@/lib/supabase/types"; +import type { Database } from "@/lib/types"; import { fmtDate, fmtName } from "@/lib/format"; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; diff --git a/components/employees/panels/TransferPanel.tsx b/components/employees/panels/TransferPanel.tsx index d70b5a1..f631816 100644 --- a/components/employees/panels/TransferPanel.tsx +++ b/components/employees/panels/TransferPanel.tsx @@ -8,7 +8,7 @@ import { SelectField, TextField } from "@/components/ui/Field"; import { SlideOver } from "@/components/ui/SlideOver"; import { useToast } from "@/components/ui/Toast"; import type { OpenPositionResolved } from "@/lib/positions"; -import type { Database } from "@/lib/supabase/types"; +import type { Database } from "@/lib/types"; import { fmtName } from "@/lib/format"; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; diff --git a/components/employees/tabs/HistorieTab.tsx b/components/employees/tabs/HistorieTab.tsx index e382723..968a988 100644 --- a/components/employees/tabs/HistorieTab.tsx +++ b/components/employees/tabs/HistorieTab.tsx @@ -9,7 +9,7 @@ import { SegmentedControl } from "@/components/ui/SegmentedControl"; import { actionBadgeStyle } from "@/lib/colors"; import { fmtDate, todayIso } from "@/lib/format"; import { darfBearbeitetWerden, darfKorrigiertWerden, loeschVorschau } from "@/lib/history"; -import type { Database } from "@/lib/supabase/types"; +import type { Database } from "@/lib/types"; type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"]; diff --git a/components/employees/tabs/NotizenTab.tsx b/components/employees/tabs/NotizenTab.tsx index d99604e..66514a6 100644 --- a/components/employees/tabs/NotizenTab.tsx +++ b/components/employees/tabs/NotizenTab.tsx @@ -8,7 +8,7 @@ import { SelectField, TextField, TextareaField } from "@/components/ui/Field"; import { useToast } from "@/components/ui/Toast"; import { NOTE_CATEGORY_STYLES } from "@/lib/colors"; import { fmtDate } from "@/lib/format"; -import type { Database, NoteCategory } from "@/lib/supabase/types"; +import type { Database, NoteCategory } from "@/lib/types"; type Note = Database["public"]["Tables"]["employee_notes"]["Row"]; diff --git a/components/employees/tabs/StammdatenTab.tsx b/components/employees/tabs/StammdatenTab.tsx index a70c39c..a265b17 100644 --- a/components/employees/tabs/StammdatenTab.tsx +++ b/components/employees/tabs/StammdatenTab.tsx @@ -1,7 +1,7 @@ import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection"; import { brauchtAufenthaltstitel } from "@/lib/countries"; import { fmtAge, fmtDate } from "@/lib/format"; -import type { Database } from "@/lib/supabase/types"; +import type { Database } from "@/lib/types"; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; type Location = Database["public"]["Tables"]["locations"]["Row"]; diff --git a/components/employees/tabs/VertragTab.tsx b/components/employees/tabs/VertragTab.tsx index 13cfcb5..3a8d489 100644 --- a/components/employees/tabs/VertragTab.tsx +++ b/components/employees/tabs/VertragTab.tsx @@ -1,6 +1,6 @@ import { dienstwagenLabel } from "@/lib/dienstwagen"; import { fmtDate } from "@/lib/format"; -import type { Database } from "@/lib/supabase/types"; +import type { Database } from "@/lib/types"; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; diff --git a/components/hire/StepAngehoerige.tsx b/components/hire/StepAngehoerige.tsx index 0b39173..9c07e3d 100644 --- a/components/hire/StepAngehoerige.tsx +++ b/components/hire/StepAngehoerige.tsx @@ -3,7 +3,7 @@ import { Button } from "@/components/ui/Button"; import { SelectField, TextField } from "@/components/ui/Field"; import { fmtDate } from "@/lib/format"; import { formatSvnr, svnrErrorMessage, validateSvnr } from "@/lib/svnr"; -import type { RelationshipType } from "@/lib/supabase/types"; +import type { RelationshipType } from "@/lib/types"; import type { HireDraftAngehoerige, HireDraftData } from "./types"; const VERHAELTNIS: RelationshipType[] = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"]; diff --git a/components/hire/StepNotfallkontakt.tsx b/components/hire/StepNotfallkontakt.tsx index 0596687..fffac77 100644 --- a/components/hire/StepNotfallkontakt.tsx +++ b/components/hire/StepNotfallkontakt.tsx @@ -1,5 +1,5 @@ import { SelectField, TextField } from "@/components/ui/Field"; -import { EMERGENCY_RELATIONS } from "@/lib/supabase/types"; +import { EMERGENCY_RELATIONS } from "@/lib/types"; import type { HireDraftData } from "./types"; // Eigener Schritt, kurz vor der Zusammenfassung. diff --git a/components/hire/StepVertrag.tsx b/components/hire/StepVertrag.tsx index eef7a96..2eb7c2f 100644 --- a/components/hire/StepVertrag.tsx +++ b/components/hire/StepVertrag.tsx @@ -1,6 +1,6 @@ import { RoleEmploymentFields } from "@/components/employees/RoleEmploymentFields"; import { SelectField, TextField } from "@/components/ui/Field"; -import type { PaygradeType } from "@/lib/supabase/types"; +import type { PaygradeType } from "@/lib/types"; import type { HireDraftData } from "./types"; const PAYGRADES: { value: PaygradeType; label: string; description: string }[] = [ diff --git a/components/hire/types.ts b/components/hire/types.ts index 52702f9..2005f8c 100644 --- a/components/hire/types.ts +++ b/components/hire/types.ts @@ -1,4 +1,4 @@ -import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, PaygradeType, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types"; +import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, PaygradeType, RelationshipType, Weekday, WorkerType } from "@/lib/types"; // The spec's hire wizard field list (§4.4) omits Geschlecht and Standort even // though both are NOT NULL on employees — added here (defaults keep them diff --git a/components/reports/ReportsPageClient.tsx b/components/reports/ReportsPageClient.tsx index d94e4cc..10c8e72 100644 --- a/components/reports/ReportsPageClient.tsx +++ b/components/reports/ReportsPageClient.tsx @@ -37,7 +37,7 @@ import { type ReportRow, type UnitOption, } from "@/lib/reports"; -import type { HistoryEventType } from "@/lib/supabase/types"; +import type { HistoryEventType } from "@/lib/types"; const SPLIT_COLORS = ["bg-brand-500", "bg-info-text", "bg-purple-text", "bg-warning-text", "bg-success-text", "bg-danger-solid"]; const EVENT_TYPES = Object.keys(EVENT_TYPE_LABELS) as HistoryEventType[]; diff --git a/components/ui/AenderungsTabelle.tsx b/components/ui/AenderungsTabelle.tsx index 3dd2a91..cec5964 100644 --- a/components/ui/AenderungsTabelle.tsx +++ b/components/ui/AenderungsTabelle.tsx @@ -1,4 +1,4 @@ -import type { AuditChange } from "@/lib/supabase/types"; +import type { AuditChange } from "@/lib/types"; // Was sich geändert hat, feldweise — im Protokoll und in der Historie einer // Person dieselbe Darstellung. Zwei Ansichten derselben Sache verschieden zu diff --git a/components/ui/StatusChip.tsx b/components/ui/StatusChip.tsx index da90cf7..6410ef6 100644 --- a/components/ui/StatusChip.tsx +++ b/components/ui/StatusChip.tsx @@ -1,7 +1,7 @@ import { absenceLabel } from "@/lib/absence"; import { STATUS_STYLES } from "@/lib/colors"; import { fmtDate } from "@/lib/format"; -import type { EmploymentStatus } from "@/lib/supabase/types"; +import type { EmploymentStatus } from "@/lib/types"; type StatusChipProps = { status: EmploymentStatus; diff --git a/supabase/migrations/20260601000000_initial_schema.sql b/db/migrations/20260601000000_initial_schema.sql similarity index 100% rename from supabase/migrations/20260601000000_initial_schema.sql rename to db/migrations/20260601000000_initial_schema.sql diff --git a/supabase/migrations/20260601000100_nationality_country_free_text.sql b/db/migrations/20260601000100_nationality_country_free_text.sql similarity index 100% rename from supabase/migrations/20260601000100_nationality_country_free_text.sql rename to db/migrations/20260601000100_nationality_country_free_text.sql diff --git a/supabase/migrations/20260601000200_business_mutation_functions.sql b/db/migrations/20260601000200_business_mutation_functions.sql similarity index 100% rename from supabase/migrations/20260601000200_business_mutation_functions.sql rename to db/migrations/20260601000200_business_mutation_functions.sql diff --git a/supabase/migrations/20260601000300_start_karenz_function.sql b/db/migrations/20260601000300_start_karenz_function.sql similarity index 100% rename from supabase/migrations/20260601000300_start_karenz_function.sql rename to db/migrations/20260601000300_start_karenz_function.sql diff --git a/supabase/migrations/20260601000400_reorg_undo_history_delete_policy.sql b/db/migrations/20260601000400_reorg_undo_history_delete_policy.sql similarity index 100% rename from supabase/migrations/20260601000400_reorg_undo_history_delete_policy.sql rename to db/migrations/20260601000400_reorg_undo_history_delete_policy.sql diff --git a/supabase/migrations/20260601000500_change_employee_data_effective_date.sql b/db/migrations/20260601000500_change_employee_data_effective_date.sql similarity index 100% rename from supabase/migrations/20260601000500_change_employee_data_effective_date.sql rename to db/migrations/20260601000500_change_employee_data_effective_date.sql diff --git a/supabase/migrations/20260714120000_hr_only_access.sql b/db/migrations/20260714120000_hr_only_access.sql similarity index 100% rename from supabase/migrations/20260714120000_hr_only_access.sql rename to db/migrations/20260714120000_hr_only_access.sql diff --git a/supabase/migrations/20260714120050_pending_org_changes.sql b/db/migrations/20260714120050_pending_org_changes.sql similarity index 100% rename from supabase/migrations/20260714120050_pending_org_changes.sql rename to db/migrations/20260714120050_pending_org_changes.sql diff --git a/supabase/migrations/20260714120100_salary_deprecation.sql b/db/migrations/20260714120100_salary_deprecation.sql similarity index 100% rename from supabase/migrations/20260714120100_salary_deprecation.sql rename to db/migrations/20260714120100_salary_deprecation.sql diff --git a/supabase/migrations/20260714120200_effective_dating_rpcs.sql b/db/migrations/20260714120200_effective_dating_rpcs.sql similarity index 100% rename from supabase/migrations/20260714120200_effective_dating_rpcs.sql rename to db/migrations/20260714120200_effective_dating_rpcs.sql diff --git a/supabase/migrations/20260714120300_reorg_undo_append_only.sql b/db/migrations/20260714120300_reorg_undo_append_only.sql similarity index 100% rename from supabase/migrations/20260714120300_reorg_undo_append_only.sql rename to db/migrations/20260714120300_reorg_undo_append_only.sql diff --git a/supabase/migrations/20260714120400_performance_indexes.sql b/db/migrations/20260714120400_performance_indexes.sql similarity index 100% rename from supabase/migrations/20260714120400_performance_indexes.sql rename to db/migrations/20260714120400_performance_indexes.sql diff --git a/supabase/migrations/20260714120500_default_grants.sql b/db/migrations/20260714120500_default_grants.sql similarity index 100% rename from supabase/migrations/20260714120500_default_grants.sql rename to db/migrations/20260714120500_default_grants.sql diff --git a/supabase/migrations/20260714120600_data_integrity_guards.sql b/db/migrations/20260714120600_data_integrity_guards.sql similarity index 100% rename from supabase/migrations/20260714120600_data_integrity_guards.sql rename to db/migrations/20260714120600_data_integrity_guards.sql diff --git a/supabase/migrations/20260715120000_split_address_fields.sql b/db/migrations/20260715120000_split_address_fields.sql similarity index 100% rename from supabase/migrations/20260715120000_split_address_fields.sql rename to db/migrations/20260715120000_split_address_fields.sql diff --git a/supabase/migrations/20260716120000_position_validity_and_delete.sql b/db/migrations/20260716120000_position_validity_and_delete.sql similarity index 100% rename from supabase/migrations/20260716120000_position_validity_and_delete.sql rename to db/migrations/20260716120000_position_validity_and_delete.sql diff --git a/supabase/migrations/20260718120000_role_employment_fields.sql b/db/migrations/20260718120000_role_employment_fields.sql similarity index 100% rename from supabase/migrations/20260718120000_role_employment_fields.sql rename to db/migrations/20260718120000_role_employment_fields.sql diff --git a/supabase/migrations/20260718140000_employee_dependents.sql b/db/migrations/20260718140000_employee_dependents.sql similarity index 100% rename from supabase/migrations/20260718140000_employee_dependents.sql rename to db/migrations/20260718140000_employee_dependents.sql diff --git a/supabase/migrations/20260718150000_person_titles.sql b/db/migrations/20260718150000_person_titles.sql similarity index 100% rename from supabase/migrations/20260718150000_person_titles.sql rename to db/migrations/20260718150000_person_titles.sql diff --git a/supabase/migrations/20260718160000_dependents_effective_dating.sql b/db/migrations/20260718160000_dependents_effective_dating.sql similarity index 100% rename from supabase/migrations/20260718160000_dependents_effective_dating.sql rename to db/migrations/20260718160000_dependents_effective_dating.sql diff --git a/supabase/migrations/20260719120000_employee_notes.sql b/db/migrations/20260719120000_employee_notes.sql similarity index 100% rename from supabase/migrations/20260719120000_employee_notes.sql rename to db/migrations/20260719120000_employee_notes.sql diff --git a/supabase/migrations/20260724120000_employee_assignment_history.sql b/db/migrations/20260724120000_employee_assignment_history.sql similarity index 100% rename from supabase/migrations/20260724120000_employee_assignment_history.sql rename to db/migrations/20260724120000_employee_assignment_history.sql diff --git a/supabase/migrations/20260725120000_svnr_validation.sql b/db/migrations/20260725120000_svnr_validation.sql similarity index 100% rename from supabase/migrations/20260725120000_svnr_validation.sql rename to db/migrations/20260725120000_svnr_validation.sql diff --git a/supabase/migrations/20260726120000_absence_type.sql b/db/migrations/20260726120000_absence_type.sql similarity index 100% rename from supabase/migrations/20260726120000_absence_type.sql rename to db/migrations/20260726120000_absence_type.sql diff --git a/supabase/migrations/20260727120000_sap_om_org_model.sql b/db/migrations/20260727120000_sap_om_org_model.sql similarity index 100% rename from supabase/migrations/20260727120000_sap_om_org_model.sql rename to db/migrations/20260727120000_sap_om_org_model.sql diff --git a/supabase/migrations/20260727120100_om_reporting_lines.sql b/db/migrations/20260727120100_om_reporting_lines.sql similarity index 100% rename from supabase/migrations/20260727120100_om_reporting_lines.sql rename to db/migrations/20260727120100_om_reporting_lines.sql diff --git a/supabase/migrations/20260727120200_om_cutover.sql b/db/migrations/20260727120200_om_cutover.sql similarity index 100% rename from supabase/migrations/20260727120200_om_cutover.sql rename to db/migrations/20260727120200_om_cutover.sql diff --git a/supabase/migrations/20260727130000_om_cleanup_and_positions.sql b/db/migrations/20260727130000_om_cleanup_and_positions.sql similarity index 100% rename from supabase/migrations/20260727130000_om_cleanup_and_positions.sql rename to db/migrations/20260727130000_om_cleanup_and_positions.sql diff --git a/supabase/migrations/20260727140000_pin_function_search_path.sql b/db/migrations/20260727140000_pin_function_search_path.sql similarity index 100% rename from supabase/migrations/20260727140000_pin_function_search_path.sql rename to db/migrations/20260727140000_pin_function_search_path.sql diff --git a/supabase/migrations/20260727150000_revoke_definer_execute.sql b/db/migrations/20260727150000_revoke_definer_execute.sql similarity index 100% rename from supabase/migrations/20260727150000_revoke_definer_execute.sql rename to db/migrations/20260727150000_revoke_definer_execute.sql diff --git a/supabase/migrations/20260730120000_app_users_and_session_context.sql b/db/migrations/20260730120000_app_users_and_session_context.sql similarity index 100% rename from supabase/migrations/20260730120000_app_users_and_session_context.sql rename to db/migrations/20260730120000_app_users_and_session_context.sql diff --git a/supabase/migrations/20260731090000_app_upsert_user.sql b/db/migrations/20260731090000_app_upsert_user.sql similarity index 100% rename from supabase/migrations/20260731090000_app_upsert_user.sql rename to db/migrations/20260731090000_app_upsert_user.sql diff --git a/supabase/migrations/20260803120000_import_sequence_grant.sql b/db/migrations/20260803120000_import_sequence_grant.sql similarity index 100% rename from supabase/migrations/20260803120000_import_sequence_grant.sql rename to db/migrations/20260803120000_import_sequence_grant.sql diff --git a/supabase/migrations/20260803140000_audit_changes_detail.sql b/db/migrations/20260803140000_audit_changes_detail.sql similarity index 100% rename from supabase/migrations/20260803140000_audit_changes_detail.sql rename to db/migrations/20260803140000_audit_changes_detail.sql diff --git a/supabase/migrations/20260805100000_fix_stale_type_casts.sql b/db/migrations/20260805100000_fix_stale_type_casts.sql similarity index 100% rename from supabase/migrations/20260805100000_fix_stale_type_casts.sql rename to db/migrations/20260805100000_fix_stale_type_casts.sql diff --git a/supabase/migrations/20260805110000_replace_auth_uid_in_functions.sql b/db/migrations/20260805110000_replace_auth_uid_in_functions.sql similarity index 100% rename from supabase/migrations/20260805110000_replace_auth_uid_in_functions.sql rename to db/migrations/20260805110000_replace_auth_uid_in_functions.sql diff --git a/supabase/migrations/20260805120000_fix_hire_employee_precedence.sql b/db/migrations/20260805120000_fix_hire_employee_precedence.sql similarity index 100% rename from supabase/migrations/20260805120000_fix_hire_employee_precedence.sql rename to db/migrations/20260805120000_fix_hire_employee_precedence.sql diff --git a/supabase/migrations/20260805130000_update_position.sql b/db/migrations/20260805130000_update_position.sql similarity index 100% rename from supabase/migrations/20260805130000_update_position.sql rename to db/migrations/20260805130000_update_position.sql diff --git a/supabase/migrations/20260805140000_profiles_reference_app_users.sql b/db/migrations/20260805140000_profiles_reference_app_users.sql similarity index 100% rename from supabase/migrations/20260805140000_profiles_reference_app_users.sql rename to db/migrations/20260805140000_profiles_reference_app_users.sql diff --git a/supabase/migrations/20260810100000_position_validity_on_assignment.sql b/db/migrations/20260810100000_position_validity_on_assignment.sql similarity index 100% rename from supabase/migrations/20260810100000_position_validity_on_assignment.sql rename to db/migrations/20260810100000_position_validity_on_assignment.sql diff --git a/supabase/migrations/20260810110000_fix_hire_workdays_default.sql b/db/migrations/20260810110000_fix_hire_workdays_default.sql similarity index 100% rename from supabase/migrations/20260810110000_fix_hire_workdays_default.sql rename to db/migrations/20260810110000_fix_hire_workdays_default.sql diff --git a/supabase/migrations/20260810120000_rehire_position_checks.sql b/db/migrations/20260810120000_rehire_position_checks.sql similarity index 100% rename from supabase/migrations/20260810120000_rehire_position_checks.sql rename to db/migrations/20260810120000_rehire_position_checks.sql diff --git a/supabase/migrations/20260810130000_fix_rehire_status_cast.sql b/db/migrations/20260810130000_fix_rehire_status_cast.sql similarity index 100% rename from supabase/migrations/20260810130000_fix_rehire_status_cast.sql rename to db/migrations/20260810130000_fix_rehire_status_cast.sql diff --git a/supabase/migrations/20260811100000_personnel_number_is_entered.sql b/db/migrations/20260811100000_personnel_number_is_entered.sql similarity index 100% rename from supabase/migrations/20260811100000_personnel_number_is_entered.sql rename to db/migrations/20260811100000_personnel_number_is_entered.sql diff --git a/supabase/migrations/20260811110000_emergency_contact_and_car_type.sql b/db/migrations/20260811110000_emergency_contact_and_car_type.sql similarity index 100% rename from supabase/migrations/20260811110000_emergency_contact_and_car_type.sql rename to db/migrations/20260811110000_emergency_contact_and_car_type.sql diff --git a/supabase/migrations/20260811120000_change_data_covers_new_fields.sql b/db/migrations/20260811120000_change_data_covers_new_fields.sql similarity index 100% rename from supabase/migrations/20260811120000_change_data_covers_new_fields.sql rename to db/migrations/20260811120000_change_data_covers_new_fields.sql diff --git a/supabase/migrations/20260811130000_hire_takes_new_fields.sql b/db/migrations/20260811130000_hire_takes_new_fields.sql similarity index 100% rename from supabase/migrations/20260811130000_hire_takes_new_fields.sql rename to db/migrations/20260811130000_hire_takes_new_fields.sql diff --git a/supabase/migrations/20260811140000_private_email_optional.sql b/db/migrations/20260811140000_private_email_optional.sql similarity index 100% rename from supabase/migrations/20260811140000_private_email_optional.sql rename to db/migrations/20260811140000_private_email_optional.sql diff --git a/supabase/migrations/20260813120000_history_carries_changes.sql b/db/migrations/20260813120000_history_carries_changes.sql similarity index 100% rename from supabase/migrations/20260813120000_history_carries_changes.sql rename to db/migrations/20260813120000_history_carries_changes.sql diff --git a/supabase/migrations/20260813140000_delete_history_entry.sql b/db/migrations/20260813140000_delete_history_entry.sql similarity index 100% rename from supabase/migrations/20260813140000_delete_history_entry.sql rename to db/migrations/20260813140000_delete_history_entry.sql diff --git a/supabase/migrations/20260813160000_revert_in_one_statement.sql b/db/migrations/20260813160000_revert_in_one_statement.sql similarity index 100% rename from supabase/migrations/20260813160000_revert_in_one_statement.sql rename to db/migrations/20260813160000_revert_in_one_statement.sql diff --git a/supabase/migrations/20260813180000_update_history_entry.sql b/db/migrations/20260813180000_update_history_entry.sql similarity index 100% rename from supabase/migrations/20260813180000_update_history_entry.sql rename to db/migrations/20260813180000_update_history_entry.sql diff --git a/supabase/migrations/20260813200000_history_links_to_pending.sql b/db/migrations/20260813200000_history_links_to_pending.sql similarity index 100% rename from supabase/migrations/20260813200000_history_links_to_pending.sql rename to db/migrations/20260813200000_history_links_to_pending.sql diff --git a/supabase/migrations/20260814100000_no_show.sql b/db/migrations/20260814100000_no_show.sql similarity index 100% rename from supabase/migrations/20260814100000_no_show.sql rename to db/migrations/20260814100000_no_show.sql diff --git a/supabase/migrations/20260814120000_kuendigungsschutz.sql b/db/migrations/20260814120000_kuendigungsschutz.sql similarity index 100% rename from supabase/migrations/20260814120000_kuendigungsschutz.sql rename to db/migrations/20260814120000_kuendigungsschutz.sql diff --git a/supabase/migrations/20260814140000_teilzeit_gruende.sql b/db/migrations/20260814140000_teilzeit_gruende.sql similarity index 100% rename from supabase/migrations/20260814140000_teilzeit_gruende.sql rename to db/migrations/20260814140000_teilzeit_gruende.sql diff --git a/supabase/migrations/20260814160000_teilzeit_art.sql b/db/migrations/20260814160000_teilzeit_art.sql similarity index 100% rename from supabase/migrations/20260814160000_teilzeit_art.sql rename to db/migrations/20260814160000_teilzeit_art.sql diff --git a/supabase/migrations/20260814170000_teilzeit_bei_geplanter_rueckkehr.sql b/db/migrations/20260814170000_teilzeit_bei_geplanter_rueckkehr.sql similarity index 100% rename from supabase/migrations/20260814170000_teilzeit_bei_geplanter_rueckkehr.sql rename to db/migrations/20260814170000_teilzeit_bei_geplanter_rueckkehr.sql diff --git a/supabase/migrations/20260814180000_karenz_ruecknahme.sql b/db/migrations/20260814180000_karenz_ruecknahme.sql similarity index 100% rename from supabase/migrations/20260814180000_karenz_ruecknahme.sql rename to db/migrations/20260814180000_karenz_ruecknahme.sql diff --git a/supabase/migrations/20260814200000_aufenthaltstitel.sql b/db/migrations/20260814200000_aufenthaltstitel.sql similarity index 100% rename from supabase/migrations/20260814200000_aufenthaltstitel.sql rename to db/migrations/20260814200000_aufenthaltstitel.sql diff --git a/supabase/migrations/20260815100000_eintrittsdatum_und_rueckkehr_regel.sql b/db/migrations/20260815100000_eintrittsdatum_und_rueckkehr_regel.sql similarity index 100% rename from supabase/migrations/20260815100000_eintrittsdatum_und_rueckkehr_regel.sql rename to db/migrations/20260815100000_eintrittsdatum_und_rueckkehr_regel.sql diff --git a/supabase/migrations/20260815120000_geplante_abwesenheit_abbrechen.sql b/db/migrations/20260815120000_geplante_abwesenheit_abbrechen.sql similarity index 100% rename from supabase/migrations/20260815120000_geplante_abwesenheit_abbrechen.sql rename to db/migrations/20260815120000_geplante_abwesenheit_abbrechen.sql diff --git a/supabase/migrations/20260816100000_kostenstellen.sql b/db/migrations/20260816100000_kostenstellen.sql similarity index 100% rename from supabase/migrations/20260816100000_kostenstellen.sql rename to db/migrations/20260816100000_kostenstellen.sql diff --git a/supabase/migrations/20260817100000_onboarding_checkliste.sql b/db/migrations/20260817100000_onboarding_checkliste.sql similarity index 100% rename from supabase/migrations/20260817100000_onboarding_checkliste.sql rename to db/migrations/20260817100000_onboarding_checkliste.sql diff --git a/supabase/migrations/20260818100000_offboarding_checkliste.sql b/db/migrations/20260818100000_offboarding_checkliste.sql similarity index 100% rename from supabase/migrations/20260818100000_offboarding_checkliste.sql rename to db/migrations/20260818100000_offboarding_checkliste.sql diff --git a/supabase/migrations/20260819100000_rls_sicherheitsnetz_als_migration.sql b/db/migrations/20260819100000_rls_sicherheitsnetz_als_migration.sql similarity index 100% rename from supabase/migrations/20260819100000_rls_sicherheitsnetz_als_migration.sql rename to db/migrations/20260819100000_rls_sicherheitsnetz_als_migration.sql diff --git a/supabase/migrations/20260826120000_anwendungsrolle_rechte.sql b/db/migrations/20260826120000_anwendungsrolle_rechte.sql similarity index 100% rename from supabase/migrations/20260826120000_anwendungsrolle_rechte.sql rename to db/migrations/20260826120000_anwendungsrolle_rechte.sql diff --git a/db/migrations/20260907100000_alte_buchfuehrung_ablegen.sql b/db/migrations/20260907100000_alte_buchfuehrung_ablegen.sql new file mode 100644 index 0000000..ccc5b45 --- /dev/null +++ b/db/migrations/20260907100000_alte_buchfuehrung_ablegen.sql @@ -0,0 +1,27 @@ +-- Die Buchführung der Migrationen heisst jetzt `migrationen`, nicht mehr +-- `supabase_migrations`. Hier wird das alte Schema abgeräumt. +-- +-- ═══ Warum das gefahrlos ist ═══ +-- +-- Die Reihenfolge trägt die ganze Sicherheit: +-- +-- 1. scripts/migrate.mjs legt `migrationen.schema_migrations` an und +-- übernimmt einmalig alle Einträge aus `supabase_migrations`, falls es +-- die Tabelle gibt und die neue noch leer ist. +-- 2. Erst danach sucht er nach ausstehenden Migrationen — und findet diese +-- hier. +-- 3. Diese Datei löscht das alte Schema. +-- +-- Zum Zeitpunkt von Schritt 3 stehen die Einträge also bereits doppelt. Wer +-- die Datei einzeln und von Hand einspielt, ohne Schritt 1, verliert dagegen +-- die Buchführung — dann hielte der Läufer alle Migrationen für ausstehend. +-- Deshalb: nur über `node scripts/migrate.mjs` einspielen, nie direkt. +-- +-- Auf einer frischen Datenbank hat es das Schema nie gegeben; `if exists` +-- macht die Migration dort zu einem Nichts. +-- +-- `cascade` statt `restrict`: das Schema enthält ausschliesslich die eine +-- Tabelle, deren Inhalt eine Zeile weiter oben schon übernommen wurde. Ohne +-- cascade bliebe ein leeres Schema stehen, das niemand mehr anfasst. + +drop schema if exists supabase_migrations cascade; diff --git a/docker-compose.yml b/docker-compose.yml index c94dd03..633a7bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ services: - # Die Datenbank läuft jetzt hier statt bei Supabase. + # Die Datenbank gehört zum Projekt und läuft hier mit. # # Was das Schema von der Plattform brauchte, bringt deploy/db-init mit: # die Anwendungsrolle ohne BYPASSRLS, zwei Erweiterungen und eine Attrappe diff --git a/docs/azure-migration.md b/docs/azure-migration.md deleted file mode 100644 index 702cdca..0000000 --- a/docs/azure-migration.md +++ /dev/null @@ -1,148 +0,0 @@ -# Umstieg auf Azure — Sicherheitsarchitektur - -Entwurf zur Abnahme. **Noch kein Code umgestellt.** - -Ziel: Azure Database for PostgreSQL (Flexible Server), Anmeldung über Entra ID, -Supabase als Abhängigkeit entfernt. - -## Ausgangslage, gemessen - -| | Anzahl | -|---|---| -| RLS-Policies | 21 | -| `auth.uid()` / `auth.users` in Migrationen | 79 | -| Fremdschlüssel auf `auth.users` | 9 | -| Datenzugriffe in der App (`.from()`, `.rpc()`) | 50 | -| Dateien mit Supabase-Import | 10 | - -Die Anwendung läuft mit dem **anon-Key** (`lib/supabase/server.ts`, -`client.ts`); der Service-Role-Key kommt nur in `lib/supabase/admin.ts` vor. -Die RLS-Policies sind damit die tatsächliche Sicherheitsgrenze — nicht der -Proxy und nicht der Anwendungscode. - -## Der entscheidende Befund - -`auth.uid()` erscheint 70-mal, aber für die Absicherung zählt genau **eine** -Stelle: - -```sql -create or replace function is_hr_user() returns boolean -language sql security definer stable as $$ - select exists ( - select 1 from profiles p - where p.id = auth.uid() and p.role = 'hr' and p.is_active = true - ); -$$; -``` - -Alle 21 Policies rufen `is_hr_user()` auf. Wird hier die Herkunft der -Benutzerkennung ausgetauscht, **bleiben alle Policies unverändert gültig**. -Die Sicherheitsarchitektur wandert also *nicht* in den Anwendungscode — das -war meine Sorge bei Variante B, und sie ist ausgeräumt. - -Die übrigen ~55 Vorkommen stehen in Mutations-RPCs (`insert into audit_log -values (auth.uid(), …)`) und sind eine mechanische Ersetzung. - -## Zielarchitektur - -### 1. Benutzertabelle statt `auth.users` - -```sql -create table app_users ( - id uuid primary key default gen_random_uuid(), - entra_object_id uuid not null unique, -- oid aus dem Entra-Token - email text not null, - created_at timestamptz not null default now() -); -``` - -Die neun Fremdschlüssel zeigen künftig hierauf. `profiles.id` bleibt der -Schlüssel, an dem `role` und `is_active` hängen — die HR-Freischaltung -funktioniert unverändert. - -### 2. Sitzungskontext statt `auth.uid()` - -```sql -create or replace function current_app_user() returns uuid -language sql stable as $$ - select nullif(current_setting('app.user_id', true), '')::uuid; -$$; -``` - -`auth.uid()` → `current_app_user()`, überall. `is_hr_user()` bleibt sonst -Wort für Wort gleich. - -### 3. Der kritische Punkt: wie der Kontext gesetzt wird - -**Hier entscheidet sich, ob die Migration sicher ist.** - -Jeder Datenbankzugriff muss in einer Transaktion laufen, die zuerst -`set local app.user_id` ausführt: - -```ts -await db.transaction(async (tx) => { - await tx.execute(sql`select set_config('app.user_id', ${userId}, true)`); - return tx.select()…; -}); -``` - -Das dritte Argument `true` bedeutet *transaktionslokal*. Ohne Transaktion -bliebe die Einstellung an der Verbindung hängen — und die nächste Anfrage, -die dieselbe Verbindung aus dem Pool zieht, liefe **mit der Kennung des -vorherigen Benutzers**. Das ist genau die Art Fehler, die in einem Test nie -auffällt und im Betrieb Personaldaten quer über Benutzer hinweg preisgibt. - -Deshalb: **kein direkter Zugriff auf den Pool.** Es gibt eine einzige -Zugriffsfunktion, die die Transaktion und `set_config` erzwingt, und eine -Lint-Regel, die den Import des Pools außerhalb dieser Datei verbietet. -Das muss strukturell unmöglich sein, nicht per Konvention. - -Zusätzlich verbindet sich die Anwendung mit einer Datenbankrolle **ohne** -`BYPASSRLS`. Selbst wenn der Kontext fehlt, liefern die Policies dann nichts -zurück — statt alles. - -### 4. Anmeldung - -Entra ID über NextAuth (Azure-AD-Provider) oder MSAL. Nach der Validierung -des Tokens wird die `oid` auf `app_users.entra_object_id` abgebildet; existiert -kein Eintrag, wird einer angelegt — **ohne** `profiles`-Zeile, also ohne -Zugriff. Die Freischaltung bleibt ein bewusster Schritt, wie heute -(`is_active` ist per Vorgabe `false`). - -Damit erledigt sich die SSO-Frage aus der IT-Liste mit. - -### 5. Datenzugriff - -PostgREST entfällt; die 50 Aufrufe werden auf Drizzle umgestellt. Die sechs -`.rpc()`-Aufrufe sind trivial (direkter Funktionsaufruf), die 44 -`.from()`-Aufrufe sind Query-Builder-Umschreibungen. - -Das handgeschriebene `lib/supabase/types.ts` entfällt: Drizzle erzeugt die -Typen aus dem Schema, womit auch der Schema-Drift-Prüfer überflüssig wird. - -## Was bewusst gleich bleibt - -- **Alle 21 RLS-Policies**, unverändert -- Das gesamte Schema samt Enums, Arrays, `jsonb`, PL/pgSQL, partiellen Indizes -- `pgcrypto` und `pg_trgm` (beide auf Azure freigegeben) -- Die Geschäftslogik in den RPCs - -## Reihenfolge - -1. `app_users`, `current_app_user()`, Fremdschlüssel umhängen — additiv, gegen die bestehende Datenbank testbar -2. Zugriffsschicht mit erzwungener Transaktion + `set_config`, plus Test, der den Kontextverlust nachweist -3. Entra-ID-Anmeldung -4. Die 50 Datenzugriffe umstellen -5. Supabase-Pakete entfernen -6. Umzug der Datenbank per `pg_dump`/`pg_restore` - -Schritt 2 ist der einzige, bei dem ein Fehler still bleibt. Dafür braucht es -einen Test, der zwei Anfragen über dieselbe gepoolte Verbindung schickt und -prüft, dass die zweite die erste nicht sieht. - -## Offene Fragen an die Kunden-IT - -- Welcher Entra-Mandant, und wer legt die App-Registrierung an? -- Gruppenbasierte Freischaltung (Entra-Gruppe „HR") oder weiter manuell über `profiles.is_active`? -- Flexible Server: Version, Region, Netzwerkzugang (Private Endpoint oder Firewall-Regeln)? -- Wer betreibt und patcht? diff --git a/docs/data-model.md b/docs/data-model.md deleted file mode 100644 index 46d367d..0000000 --- a/docs/data-model.md +++ /dev/null @@ -1,113 +0,0 @@ -# Datenmodell - -> **Veraltet — siehe `docs/datenkatalog.md`.** -> -> Dieses Dokument beschreibt den Stand vor zwei Umbauten und stimmt in -> wesentlichen Teilen nicht mehr: -> -> - Die Org-Tabellen `divisions` / `departments` / `teams` und die Tabelle -> `positions` gibt es nicht mehr. An ihrer Stelle steht das SAP-OM-Modell -> (`org_units`, `jobs`, `om_positions`, `position_assignments`) mit -> zeitabhängigen Zuordnungen. -> - Supabase Auth, der anon key und die Service-Role sind weg. Angemeldet -> wird über Auth.js gegen Entra ID, die Konten stehen in `app_users`, und -> der Zugriff läuft über eine Rolle ohne `BYPASSRLS`. -> - Die Zahl der RLS-Policies ist 21, nicht 58. -> -> Der Datenkatalog wurde aus der laufenden Datenbank erzeugt und gilt. Was -> hier noch stimmt — die Grundprinzipien und der Abschnitt zu den -> effective-dated changes — steht dort ebenfalls. - -Beschreibt das tatsächliche Supabase-Schema (siehe `supabase/migrations/`), -nicht ein generisches HR-Schema. Quelle der Wahrheit sind immer die -Migrationen; dieses Dokument ist eine lesbare Zusammenfassung und wird bei -strukturellen Änderungen mitgepflegt. - -## Grundprinzipien - -- **Person ist nicht Position.** `employees` (Personen) und `positions` - (Planstellen/Ausschreibungen) sind getrennte Tabellen. Eine Position wird - bei Einstellung mit einer Person verknüpft (`filled_by_employee_id`), - existiert aber unabhängig davon (offene Ausschreibung). -- **History ist append-only.** `employee_history` (Ereignisse pro Person) - und `audit_log` (systemweit, wer hat was wann geändert) haben keine - Update-/Delete-Policy — RLS erlaubt nur `select`/`insert`. Korrekturen - erfolgen durch einen neuen, kompensierenden Eintrag, nie durch Ändern der - Historie (siehe `undo_reorg`, das eine "Reorganisation rückgängig"-Zeile - anhängt statt die ursprünglichen Zeilen zu löschen). -- **Audit-Log ist Pflicht bei Änderungen — und lebt in der Datenbank, nicht - im App-Code.** Jede mutierende SQL-Funktion (`hire_employee`, - `change_employee_data`, `add_employee_dependent`, `add_employee_note`, …) - schreibt ihren `audit_log`-Eintrag in derselben Transaktion wie die - eigentliche Änderung. Das ist bewusst atomar: ein fehlgeschlagener - Audit-Insert lässt die ganze Transaktion fehlschlagen, statt still eine - Änderung ohne Log zu hinterlassen. Es gibt keinen App-seitigen - `writeAuditLog()`-Helper und es sollte auch keinen geben — das würde eine - zweite, nicht-atomare Logging-Quelle neben der bestehenden schaffen. -- **Service-Role-Zugriff ist server-only.** `lib/supabase/admin.ts` ist die - einzige Stelle, die den Service-Role-Key verwendet; `import "server-only"` - macht einen versehentlichen Client-Import zu einem Build-Fehler. Alles - andere läuft über den anon key + RLS. -- **RLS ist bereits aktiv**, nicht nur für die Produktion vorgemerkt: jede - Tabelle hat `enable row level security` plus mindestens eine Policy - (siehe unten). Zusätzlich existieren explizite `grant`-Statements für - `anon`/`authenticated`/`service_role` - (`20260714120500_default_grants.sql`) — ohne die schlägt jede Query auch - mit korrekter RLS-Policy mit "permission denied" fehl, weil Postgres - Objekt-Rechte unabhängig von RLS prüft. - -## Zugriffsmodell - -Ein einziges Rollenmodell, kein Mehrfach-Rollen-System: - -- `profiles.role` ist per Check-Constraint auf den einzigen Wert `'hr'` - fixiert (Migration `20260714120000_hr_only_access.sql`). -- `profiles.is_active` (default `false`) muss zusätzlich wahr sein. -- Die SQL-Funktion `is_hr_user()` (SECURITY DEFINER, vermeidet RLS-Rekursion - auf `profiles`) prüft beides und gated praktisch jede Policy im Schema. -- `proxy.ts` (Next.js Proxy, ehem. Middleware) spiegelt dieselbe Prüfung auf - App-Ebene: nicht eingeloggt → `/login`; eingeloggt aber nicht aktive - HR-Person → `/login` mit Fehlermeldung. Das ist bewusst nur "defense in - depth" — die eigentliche Schranke ist RLS, nicht die UI-Prüfung. -- Es gibt **keine** granulareren Rollen (kein `hr_admin`/`read_only`/ - `it_admin`-Split o. Ä.) und aktuell keinen zweiten Anwendungsfall dafür. - Sollte das nötig werden, ist der richtige Ansatz eine neue Migration, die - `is_hr_user()` um echte Rollenspalten erweitert — nicht ein - App-seitiges Rollenmodell, das der DB-Policy-Ebene nicht entspricht. - -## Kernentitäten - -| Tabelle | Zweck | -|---|---| -| `divisions` / `departments` / `teams` | Org-Hierarchie ("Bereich" 20xx / "Abteilung" 21xx / "Team" 22xx), je mit eindeutiger `org_number`. | -| `locations` | Standorte, an ein Land gebunden (steuert die Standort-Picklist im UI). | -| `profiles` | Ein Datensatz pro Supabase-Auth-User; Rolle + Aktivierungsstatus (siehe oben). | -| `employees` | Zentrale Personentabelle: Stammdaten, Vertrag (Vollzeit/Teilzeit, befristet/unbefristet), Org-Zuordnung, Status (`Aktiv`/`Karenz`/`Geplant`/`Ausgetreten`), Rolle & Anstellung (Angestellte:r/Arbeiter:in, Kollektivvertrag, Arbeitstage, Betriebsrat/Dienstwagen/laterale Führung/C-Level-Flags), akademische Titel (Prefix/Suffix-Arrays). | -| `employee_history` | Append-only Ereignis-Timeline pro Person (fester Enum: Eintritt, Beförderung, Versetzung, Karenz, Vertragsänderung, Stammdatenänderung, Austritt, Wiedereintritt, Reorganisation, Gehaltsanpassung, Rückkehr). | -| `employee_dependents` | Angehörige (Ehepartner:in/Lebenspartner:in/Kind/Sonstige) je Mitarbeiter:in; Edit = Löschen + Neuanlage, kein In-place-Update. | -| `employee_notes` | HR-Notizen je Mitarbeiter:in, add-only, mit optionalem "Wiedervorlage am"-Datum. Bewusst nicht autor-gescoped — jede aktive HR-Person sieht jede offene Notiz ("Meine Notizen" ist ein geteiltes Postfach, kein persönliches). | -| `positions` | Planstellen mit eindeutiger `position_number` (^6\d{7}$), Status `open`/`filled`, `valid_from`-Gültigkeitsfenster. | -| `hire_drafts` | Fortsetzbarer Zwischenstand des Neueinstellungs-Wizards (JSONB-Payload), eigentümer-gescoped. | -| `saved_reports` | Gespeicherte Report-Konfigurationen, eigentümer-gescoped. | -| `pending_org_changes` | Effective-dated (zukünftig wirksame) Änderungen — Versetzung/Beförderung/Karenz-Start/-Rückkehr/Vertragsänderung/Reorg — die erst am `effective_date` angewendet werden. Wird von `apply_due_pending_changes()` verarbeitet, aufgerufen vom Cron-Route-Handler. Entspricht dem, was in generischen HR-Schemata oft "planned_changes" heißt. | -| `audit_log` | Systemweiter, unveränderlicher Audit-Trail (wer/wann/was/an wem). Wird ausschließlich von SQL-Funktionen beschrieben, nie direkt aus der App. | -| `reorg_scenarios` / `reorg_moves` | Persistierte Reorg-Pläne inkl. `undo_snapshot` (Pre-Change-Zustand für die Rückgängig-Funktion). | - -## Cron / effective-dated changes - -`apply_due_pending_changes()` (SQL, `SECURITY DEFINER`) ist die einzige -Funktion, deren Ausführungsrecht explizit auf `service_role` beschränkt ist -(`revoke ... from public, anon, authenticated; grant ... to service_role`). -Sie wird von `app/api/cron/apply-pending-changes/route.ts` aufgerufen — -täglich vom `cron`-Container aus `docker-compose.yml`. Die Route selbst -authentifiziert per `CRON_SECRET`-Bearer-Token, nicht über eine Sitzung — es -gibt keine anfragende Person, nur den Zeitplan. - -Idempotenz: `pending_org_changes.status` läuft `pending` → `applied` (oder -`cancelled` bei Reorg-Undo); die Auswahl-Query filtert immer auf -`status = 'pending'`, ein zweiter Lauf wirkt daher auf bereits angewendete -Einträge nicht erneut. - -## Bekannte Lücken vor Produktivbetrieb - -Siehe README.md → "Known TODOs" für den aktuellen Stand. diff --git a/docs/datenkatalog.md b/docs/datenkatalog.md index 64d4229..1d4acbf 100644 --- a/docs/datenkatalog.md +++ b/docs/datenkatalog.md @@ -3,18 +3,13 @@ Jede Tabelle, jede Spalte, jede Regel — ausgelesen aus der laufenden Datenbank am **13.08.2026**. -Die Wahrheit steht in `supabase/migrations/` (48 Dateien). Dieses Dokument +Die Wahrheit steht in `db/migrations/` (68 Dateien). Dieses Dokument ist eine lesbare Fassung davon und wurde nicht abgetippt, sondern aus dem Systemkatalog erzeugt: Spaltentypen, Vorgabewerte, Schlüssel und Prüfbedingungen stammen aus `information_schema` und `pg_catalog`. Wo unten eine Regel in Worten steht, steht daneben, aus welcher `CHECK`-Bedingung sie kommt. -> **Nicht verwechseln mit `docs/data-model.md`.** Das Dokument beschreibt den -> Stand vor der Umstellung auf das SAP-OM-Modell und auf Auth.js — es nennt -> Tabellen (`divisions`, `departments`, `teams`, `positions`), die es nicht -> mehr gibt. Bei Widerspruch gilt dieser Katalog. - | | | |---|---| | Tabellen | 15 | @@ -255,7 +250,7 @@ Nachweis soll lesbar bleiben, auch wenn das Benutzerkonto später verschwindet. ### `app_users` -Ersetzt `auth.users` aus der Supabase-Zeit. `external_id` ist die `oid` aus +Ersetzt `auth.users` aus der abgelösten Plattform. `external_id` ist die `oid` aus Entra ID — **nicht** die E-Mail-Adresse, die kann sich ändern. Angelegt wird die Zeile bei der ersten Anmeldung durch `app_upsert_user()`. diff --git a/docs/security-review.md b/docs/security-review.md index 9b030f1..f600bd1 100644 --- a/docs/security-review.md +++ b/docs/security-review.md @@ -1,113 +1,68 @@ -# Security Review +# Sicherheitsprüfung -Ergebnis eines gezielten Greps über das gesamte Repository (ohne -`node_modules`) nach neun sicherheitsrelevanten Mustern, mit Bewertung im -jeweiligen Kontext. Stand: 2026-07-24. +## Der Stand vom Juli 2026 ist überholt -## `SUPABASE_SERVICE_ROLE_KEY` +Die damalige Prüfung untersuchte einen Dienstschlüssel, der den Zeilenschutz +aushebelte, einen Cookie-Adapter der abgelösten Plattform und ein +Anmeldemodell über `auth.users`. **Alle drei Bestandteile gibt es nicht +mehr.** Ihre Bewertungen stehen deshalb nicht mehr hier — ein Befund über eine +Datei, die entfernt wurde, sagt nichts über den heutigen Zustand, verleitet +aber dazu, ihn für geprüft zu halten. -Referenziert in vier Dateien, alle server-seitig / lokal: +Was nachweislich weg ist: -- `lib/supabase/admin.ts` — die einzige App-Laufzeit-Verwendung, hinter - `import "server-only"`. Jetzt mit expliziter Fehlermeldung bei fehlendem - Wert statt eines `!`-Non-null-Assertions (siehe Änderungen). -- `supabase/seed.ts`, `tests/integration/helpers.ts` — Node-Skripte - außerhalb des Next.js-Bundles (Seeding bzw. Test-Setup), lesen den Wert - nur aus `process.env`. Unkritisch. -- `.scratch_make_test_hr.mjs` — lokales, nicht eingechecktes - Hilfsskript (liest den Key ebenfalls nur aus `process.env`, kein - Hardcoding). War bisher weder committet noch von `.gitignore` erfasst; - **behoben** — `.gitignore` schließt `.scratch_*` jetzt explizit aus, damit - ein künftiges `git add -A` es nicht versehentlich eincheckt. +| Damals geprüft | Heute | +|---|---| +| `SUPABASE_SERVICE_ROLE_KEY` | Ersatzlos entfallen. Es gibt keinen Zugang, der den Zeilenschutz umgeht. | +| Postgres-Rolle `service_role` | Nur noch eine Platzhalterrolle ohne Anmelderecht, damit alte Migrationen abspielbar bleiben. | +| Cookie-Adapter der Plattform | Ersetzt durch Auth.js gegen Entra ID. | +| `auth.users` | Ersetzt durch `app_users`; kein Fremdschlüssel zeigt mehr nach `auth`. | +| Browser-Anbindung an die Datenbank | Entfallen. Der Browser spricht ausschließlich mit dieser Anwendung. | -Keine Fundstelle exponiert den Key im Browser-Bundle oder in einer -API-Response. +**Eine neue Prüfung des heutigen Aufbaus steht aus.** Bis dahin gilt: geprüft +ist, was unten steht, weil Tests es abdecken — nicht das Übrige. -## `service_role` (Postgres-Rolle) +## Was strukturell gilt und durch Tests abgedeckt ist -9 Treffer, ausschließlich in `supabase/migrations/*.sql` — Standard-Supabase- -Muster: +**Der Zeilenschutz ist die Schranke, nicht die Oberfläche.** Jede Tabelle hat +ihn aktiv, dazu 67 Regeln. Ein Sicherheitsnetz in der Datenbank schaltet ihn +für neu angelegte Tabellen selbsttätig ein, damit eine vergessene Tabelle +nicht offen steht. -- `20260714120500_default_grants.sql`: explizite `grant ... to anon, - authenticated, service_role` (nötig, weil RLS Objekt-Rechte nur - einschränkt, nicht ersetzt — ohne dieses Grant schlägt jede Query auch - mit korrekter Policy mit "permission denied" fehl). -- `20260714120200_effective_dating_rpcs.sql` / `20260714120600_...`: - `revoke execute ... from public, anon, authenticated; grant execute ... - to service_role` für `apply_due_pending_changes()` — das ist die - **korrekte** Absicherung: die Funktion darf nur vom Cron-Job (über den - Service-Role-Client) aufgerufen werden, nicht von einer eingeloggten - HR-Person, sonst könnte diese noch nicht fällige Änderungen vorzeitig - erzwingen. +**Es gibt genau einen Weg an die Datenbank.** `withUser()` setzt den +Sitzungskontext in derselben Transaktion wie die Abfrage. Die Verbindung wird +nicht ausgeleitet, und eine Linter-Regel verbietet den direkten Import des +Treibers ausserhalb von `lib/db/`. Der Nachweis dazu ist +`tests/integration/session-context.test.ts`: ohne Kontext liefert die +Berechtigungsfunktion nie wahr, und eine Kennung leckt nicht über den +Verbindungspool in die nächste Anfrage. -Keine problematische Fundstelle. +**Der Nachweis entsteht in der Datenbank, nicht im Anwendungscode.** Jede +ändernde SQL-Funktion schreibt ihren Protokolleintrag in derselben +Transaktion wie die Änderung. Ein fehlgeschlagener Eintrag lässt den ganzen +Vorgang scheitern. -## `localStorage`, `sessionStorage`, `document.cookie` +### Warum es keinen `lib/audit/audit-log.ts` gibt -Keine Treffer im gesamten App-Code. Sessions laufen ausschließlich über den -von `@supabase/ssr` verwalteten Cookie-Adapter (`lib/supabase/server.ts`, -`proxy.ts`), nicht über direkten Browser-Storage-Zugriff. Kein Risiko einer -Token-Exponierung über clientseitigen Storage. +Ein anwendungsseitiger Protokollschreiber wäre eine **zweite** Quelle neben +der bestehenden — und die einzige, die sich umgehen liesse, indem jemand die +Datenbankfunktion direkt aufruft. Die Aufgabenstellung nennt die Tabellen +`audit_logs` und `planned_changes`; im Schema heissen sie `audit_log` +(Einzahl) und `pending_org_changes`. Die vollständige Zuordnung steht im +[Datenkatalog](datenkatalog.md). -## `dangerouslySetInnerHTML`, `innerHTML` +### Der nächtliche Lauf -Keine Treffer. Keine rohe HTML-Injection-Fläche im Code. +Die Route prüft `Authorization` über `request.headers.get("authorization")`. +HTTP-Kopfzeilen sind unabhängig von Gross- und Kleinschreibung, und +`Headers.get()` behandelt sie entsprechend. Drei Tests decken das ab: +fehlende Kopfzeile, falscher Wert, nicht gesetztes `CRON_SECRET`. Die Antwort +ist jeweils `401`, nicht `500`. -## `Authorization` +## Offen -Einzige Fundstelle: `tests/unit/security.test.ts` (prüft den Cron-Route- -Guard). Die Route selbst liest den Header über -`request.headers.get("authorization")` (Kleinschreibung) — HTTP-Header sind -case-insensitiv und `Headers.get()` behandelt sie entsprechend, das ist -korrekt und wird bereits durch drei Tests abgedeckt (fehlender Header, -falscher Wert, nicht konfiguriertes `CRON_SECRET`). - -## `audit_logs` / `planned_changes` (aus der Aufgabenstellung) - -Keine Treffer unter diesen exakten Namen — das reale Schema heißt -`audit_log` (Singular) bzw. `pending_org_changes`. Siehe -[`docs/data-model.md`](data-model.md) für die vollständige Zuordnung. - -**Audit-Log-Abdeckung geprüft:** Jede mutierende Server Action (`actions/ -employees.ts`, `actions/positions.ts`, `actions/reorg.ts`) ruft ausschließlich -`supabase.rpc(...)` auf — keine einzige schreibt direkt per -`.from(...).insert()/.update()/.delete()` auf `employees`, `positions`, -`employee_notes`, `employee_dependents` oder `profiles`. Jede der -dahinterliegenden SQL-Funktionen (`hire_employee`, `transfer_employee`, -`promote_employee`, `start_karenz`, `record_karenz_return`, -`change_employee_data`, `apply_reorg`, `undo_reorg`, -`staff_position_internally`, `delete_position`, `add_employee_dependent`, -`delete_employee_dependent`, `add_employee_note`, -`complete_employee_note`) schreibt ihren `audit_log`-Eintrag in derselben -Transaktion wie die eigentliche Datenänderung. Für die aktuell existierenden -Mutationspfade ist damit lückenlos sichergestellt, dass keine Änderung ohne -Audit-Eintrag möglich ist — ein fehlgeschlagener Audit-Insert lässt die -gesamte Transaktion fehlschlagen. - -Ausnahme (bewusst, kein Gap): `apply_due_pending_changes()` (vom Cron-Job -aufgerufen) schreibt beim tatsächlichen Anwenden einer fälligen Änderung -keinen zusätzlichen `audit_log`-Eintrag — der Audit-Eintrag für diese -Änderung wurde bereits zum Zeitpunkt der Anforderung geschrieben (von -`transfer_employee`/`start_karenz`/etc.), datiert auf das Wirksamkeitsdatum. -Ein zweiter Eintrag beim tatsächlichen Anwenden würde denselben -Geschäftsvorfall doppelt loggen. - -## Warum hier kein neues `lib/audit/audit-log.ts` entstanden ist - -Eine App-seitige `writeAuditLog()`-Hilfsfunktion wäre eine zweite, -nicht-transaktionale Logging-Quelle neben der bestehenden — sie könnte -fehlschlagen, nachdem die eigentliche Mutation bereits committet wurde, und -so eine Änderung ohne Audit-Spur hinterlassen. Die bestehende Lösung -(Audit-Insert in derselben SQL-Funktion/Transaktion) ist strenger. Ein -App-seitiger Helfer wäre daher eine Verschlechterung, kein Fix. - -## Offene Punkte (siehe README → "Known TODOs") - -- Kein Content-Security-Policy-Header (bewusst zurückgestellt, siehe - Kommentar in `next.config.ts` — Skript-/Style-/Connect-Quellen noch nicht - vollständig inventarisiert). -- `.scratch_shots/` enthielt PNG-Screenshots einer Testsitzung mit - Beispiel-Notiztext ("vertrauliches Gespräch zur Verifikation" — Testdaten, - keine echten Personendaten identifiziert). Ordner ist jetzt über - `.gitignore` ausgeschlossen; Inhalt selbst wurde nicht gelöscht (siehe - Hinweis unten). +- **Neue Prüfung des heutigen Aufbaus** — Entra ID, `app_users`, `withUser()`, + der nächtliche Lauf ohne Sonderrechte. +- **Inhaltsrichtlinie scharf schalten** — sie läuft im Nur-Bericht-Modus, + bis die Meldungen sauber sind. +- **Zugangsdaten rotieren** — siehe README. diff --git a/eslint.config.mjs b/eslint.config.mjs index 3517ff0..60c971f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,7 +28,7 @@ const eslintConfig = defineConfig([ files: ["**/*.ts", "**/*.tsx"], // Tests dürfen: sie werden nicht ausgeliefert, und einige prüfen gerade // die Einstellungen des Pools — das geht nicht, ohne ihn anzusehen. - ignores: ["lib/db/**", "tests/**", "supabase/**", "scripts/**"], + ignores: ["lib/db/**", "tests/**", "scripts/**"], rules: { "no-restricted-imports": [ "error", diff --git a/lib/absence.ts b/lib/absence.ts index d1e9628..6f9f3d9 100644 --- a/lib/absence.ts +++ b/lib/absence.ts @@ -1,10 +1,10 @@ -import type { EmploymentStatus } from "./supabase/types"; +import type { EmploymentStatus } from "./types"; // Langzeitabwesenheit. // // The database still stores the employment status as 'Karenz' — renaming an // enum value would mean rewriting every stored function that spells it, for -// a label change (see supabase/migrations/*_absence_type.sql). So the value +// a label change (see db/migrations/*_absence_type.sql). So the value // is mapped to its display name here, in the one place the UI reads it from. // Nur echte Abwesenheiten. Eine Teilzeit ist keine: wer in Bildungs-, diff --git a/lib/colors.ts b/lib/colors.ts index 0b2de3d..0a4566c 100644 --- a/lib/colors.ts +++ b/lib/colors.ts @@ -1,4 +1,4 @@ -import type { EmploymentStatus, NoteCategory } from "./supabase/types"; +import type { EmploymentStatus, NoteCategory } from "./types"; const AVATAR_PALETTE = [ "#d6046e", @@ -40,7 +40,7 @@ export const CATEGORY_STYLES: Record = { }; // Audit-log / activity-feed action -> badge color, per the action list in -// supabase/schema.sql's audit_log comment. +// dem Kommentar an audit_log in der ersten Migration. const ACTION_CATEGORY: Record = { Neueinstellung: "success", Wiedereinstellung: "success", diff --git a/lib/dashboard-data.ts b/lib/dashboard-data.ts index fef3951..c8f7e5b 100644 --- a/lib/dashboard-data.ts +++ b/lib/dashboard-data.ts @@ -3,7 +3,7 @@ import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json"; import { besetzungenAbfrage, pickPlacements } from "./placement"; import { buildOrgMaps, orgMapsAbfragen, type OrgEb } from "./org"; import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions"; -import type { HistoryEventType } from "./supabase/types"; +import type { HistoryEventType } from "./types"; import type { AnstehendArt } from "./dashboard-filter"; // Was die Übersichtsseite liest — in zwei Rundreisen statt in dreizehn. diff --git a/lib/db/index.ts b/lib/db/index.ts index 0353f55..6d7fd45 100644 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -9,8 +9,8 @@ import type { Schema } from "./schema"; // ═══ Warum das keine gewöhnliche Datenbankschicht ist ═══ // // Die Zugriffsrechte liegen in der Datenbank: 21 RLS-Policies rufen -// is_hr_user() auf, und das fragt seit der Umstellung nicht mehr Supabase, -// sondern `current_setting('app.user_id')` — eine Sitzungsvariable. +// is_hr_user() auf, und das liest +// `current_setting('app.user_id')` — eine Sitzungsvariable. // // Sitzungsvariablen hängen an der *Verbindung*, nicht an der Anfrage. Und // Verbindungen kommen aus einem Pool. Wird die Variable ohne Transaktion diff --git a/lib/db/pool.ts b/lib/db/pool.ts index f5873b8..1aa654d 100644 --- a/lib/db/pool.ts +++ b/lib/db/pool.ts @@ -7,7 +7,7 @@ import { Pool, types } from "pg"; // Typprüfer und keiner der Tests fangen konnte. // // Die alte API-Schicht lieferte JSON: ein `date` kam als "2026-08-03" an, -// ein `numeric` als Zahl. Genau so steht es in lib/supabase/types.ts, und +// ein `numeric` als Zahl. Genau so steht es in lib/types.ts, und // darauf baut die gesamte Anwendung — Sortierungen mit localeCompare, // Vergleiche wie `entry_date <= stichtag`, das Ableiten des Status. // @@ -88,7 +88,7 @@ export function getPool(): Pool { statement_timeout: 20_000, idle_in_transaction_session_timeout: 20_000, connectionTimeoutMillis: 10_000, - // Verwaltete Anbieter (Azure, RDS, Supabase) verlangen TLS; lokal nicht. + // Verwaltete Anbieter (Azure, RDS) verlangen TLS; im eigenen Netz nicht. ssl: process.env.DATABASE_SSL === "false" ? undefined : { rejectUnauthorized: false }, }); diff --git a/lib/db/rpc.ts b/lib/db/rpc.ts index fade4bb..2e41281 100644 --- a/lib/db/rpc.ts +++ b/lib/db/rpc.ts @@ -1,6 +1,6 @@ import "server-only"; import { sql, withUser, type Tx } from "./index"; -import type { Database } from "@/lib/supabase/types"; +import type { Database } from "@/lib/types"; // Aufruf einer Datenbankfunktion. // diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 51a5374..952bacb 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -1,18 +1,17 @@ import type { ColumnType } from "kysely"; -import type { Database } from "@/lib/supabase/types"; +import type { Database } from "@/lib/types"; // Die Tabellenform für Kysely, abgeleitet aus der bestehenden // Schemabeschreibung — nicht daneben gestellt. // // Zwei Beschreibungen desselben Schemas driften auseinander, und die eine // hier ist bereits gegen die Migrationen abgesichert: `npm run types:check` -// vergleicht lib/supabase/types.ts Spalte für Spalte mit -// supabase/migrations/*.sql und schlägt in der CI fehl, wenn etwas fehlt. +// vergleicht lib/types.ts Spalte für Spalte mit +// db/migrations/*.sql und schlägt in der CI fehl, wenn etwas fehlt. // Diese Ableitung erbt diese Absicherung. // -// Die Datei heisst noch lib/supabase/types.ts, weil sie aus der Zeit stammt, -// als PostgREST der Zugriffsweg war. Sie beschreibt reines PostgreSQL und -// wird beim Entfernen der Supabase-Pakete lediglich umbenannt. +// Sie beschreibt reines PostgreSQL und heisst seit dem Entfernen der +// Plattform-Pakete lib/types.ts. type Tables = Database["public"]["Tables"]; diff --git a/lib/dienstwagen.ts b/lib/dienstwagen.ts index cb12a2b..f0a0348 100644 --- a/lib/dienstwagen.ts +++ b/lib/dienstwagen.ts @@ -1,4 +1,4 @@ -import type { DienstwagenArt } from "./supabase/types"; +import type { DienstwagenArt } from "./types"; // Wie ein Dienstwagen benannt wird. // diff --git a/lib/employee-status-filter.ts b/lib/employee-status-filter.ts index 5dd722f..70a8fd5 100644 --- a/lib/employee-status-filter.ts +++ b/lib/employee-status-filter.ts @@ -1,6 +1,6 @@ import type { Expression, ExpressionBuilder, SqlBool } from "kysely"; import type { Schema } from "./db/schema"; -import type { EmploymentStatus } from "./supabase/types"; +import type { EmploymentStatus } from "./types"; // The SQL counterpart of deriveStatusAsOf() in lib/reports.ts. // diff --git a/lib/history.ts b/lib/history.ts index 44b049d..0173de4 100644 --- a/lib/history.ts +++ b/lib/history.ts @@ -1,4 +1,4 @@ -import type { AuditChange, HistoryEventType } from "./supabase/types"; +import type { AuditChange, HistoryEventType } from "./types"; // Welche Historieneinträge sich zurücknehmen lassen — und warum die übrigen // nicht. diff --git a/lib/notes.ts b/lib/notes.ts index 6f20d7b..0196927 100644 --- a/lib/notes.ts +++ b/lib/notes.ts @@ -2,7 +2,7 @@ import type { Tx } from "./db"; import { jsonArrayFrom, zeitstempel } from "./db/json"; import { fmtName } from "./format"; import type { OrgEb } from "./org"; -import type { Database } from "./supabase/types"; +import type { Database } from "./types"; export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & { employeeName: string; diff --git a/lib/org.ts b/lib/org.ts index 82813f0..f04a267 100644 --- a/lib/org.ts +++ b/lib/org.ts @@ -2,7 +2,7 @@ import type { ExpressionBuilder } from "kysely"; import type { Tx } from "./db"; import { jsonArrayFrom } from "./db/json"; import type { Schema } from "./db/schema"; -import type { Database } from "./supabase/types"; +import type { Database } from "./types"; /** * Der Ausdrucksbauer einer Abfrage ohne eigene Tabelle (selectNoFrom) — das diff --git a/lib/report-criteria.ts b/lib/report-criteria.ts index 7c89368..abe4927 100644 --- a/lib/report-criteria.ts +++ b/lib/report-criteria.ts @@ -1,6 +1,6 @@ import { ABSENCE_TYPES } from "./absence"; import { parseIsoDateParam } from "./reports"; -import type { Weekday } from "./supabase/types"; +import type { Weekday } from "./types"; // Ein Verzeichnis aller Auswahlkriterien — für die Oberfläche, die Abfrage // und den Export dasselbe. diff --git a/lib/reports-data.ts b/lib/reports-data.ts index 055d1cf..f9e8e46 100644 --- a/lib/reports-data.ts +++ b/lib/reports-data.ts @@ -25,7 +25,7 @@ import type { SourceType, TeilzeitArt, WorkerType, -} from "./supabase/types"; +} from "./types"; // Shared by the Berichte page and /api/export/* so they can never drift on // what "the current view" means — same filters, same stichtag/event-window diff --git a/lib/reports.ts b/lib/reports.ts index f00ebb7..e727255 100644 --- a/lib/reports.ts +++ b/lib/reports.ts @@ -1,5 +1,5 @@ import { fmtName, todayIso, yearsBetweenIso } from "./format"; -import type { EmploymentStatus, HistoryEventType, Weekday } from "./supabase/types"; +import type { EmploymentStatus, HistoryEventType, Weekday } from "./types"; export { todayIso }; diff --git a/lib/supabase/types.ts b/lib/types.ts similarity index 91% rename from lib/supabase/types.ts rename to lib/types.ts index 5d0e3b8..abd0d61 100644 --- a/lib/supabase/types.ts +++ b/lib/types.ts @@ -1,7 +1,7 @@ -// Hand-written to match supabase/schema.sql + supabase/migrations/*.sql (no DB -// connection string available to run `supabase gen types typescript` in this -// environment — regenerate from the live project once you have the Supabase -// CLI linked). +// Von Hand gepflegt, passend zu db/migrations/*.sql. +// +// Erzeugt wird hier nichts: scripts/check-schema-types.mjs haelt die Datei +// Spalte fuer Spalte gegen die Migrationen und meldet jede Abweichung. export type EmploymentStatus = "Aktiv" | "Karenz" | "Geplant" | "Ausgetreten"; export type EmploymentType = "Vollzeit" | "Teilzeit"; @@ -94,20 +94,15 @@ export type PendingChangeType = | "reorg"; export type PendingChangeStatus = "pending" | "applied" | "cancelled"; -// @supabase/postgrest-js requires every table/view to carry a Relationships -// array (used for typed embedded selects) — left empty since no code in this -// app relies on nested/embedded resource selects. -type NoRelationships = { Relationships: [] }; - export type Database = { public: { Tables: { - locations: NoRelationships & { + locations: { Row: { id: string; name: string; country: string }; Insert: { id?: string; name: string; country: string }; Update: Partial<{ id: string; name: string; country: string }>; }; - profiles: NoRelationships & { + profiles: { Row: { id: string; email: string; @@ -139,7 +134,7 @@ export type Database = { updated_at: string; }>; }; - employees: NoRelationships & { + employees: { Row: { id: string; personnel_number: number; @@ -258,7 +253,7 @@ export type Database = { }; Update: Partial; }; - employee_history: NoRelationships & { + employee_history: { Row: { id: string; employee_id: string; @@ -283,7 +278,7 @@ export type Database = { }; Update: Partial; }; - employee_dependents: NoRelationships & { + employee_dependents: { Row: { id: string; employee_id: string; @@ -306,7 +301,7 @@ export type Database = { }; Update: Partial; }; - employee_notes: NoRelationships & { + employee_notes: { Row: { id: string; employee_id: string; @@ -335,17 +330,17 @@ export type Database = { }; Update: Partial; }; - hire_drafts: NoRelationships & { + hire_drafts: { Row: { id: string; created_by: string | null; step: number; payload: Record; updated_at: string }; Insert: { id?: string; created_by?: string | null; step?: number; payload: Record; updated_at?: string }; Update: Partial; }; - saved_reports: NoRelationships & { + saved_reports: { Row: { id: string; created_by: string | null; name: string; config: Record; created_at: string }; Insert: { id?: string; created_by?: string | null; name: string; config: Record; created_at?: string }; Update: Partial; }; - audit_log: NoRelationships & { + audit_log: { Row: { id: string; occurred_at: string; @@ -376,7 +371,7 @@ export type Database = { }; Update: Partial; }; - pending_org_changes: NoRelationships & { + pending_org_changes: { Row: { id: string; employee_id: string; @@ -404,14 +399,6 @@ export type Database = { // ── SAP-OM-Modell ────────────────────────────────────────── // O: rekursiv über parent_id, unit_type ist nur ein Etikett. org_units: { - Relationships: [ - { - foreignKeyName: "org_units_parent_id_fkey"; - columns: ["parent_id"]; - referencedRelation: "org_units"; - referencedColumns: ["id"]; - }, - ]; Row: { id: string; org_number: string; @@ -435,7 +422,7 @@ export type Database = { Update: Partial; }; // C: Katalog der Tätigkeiten. - jobs: NoRelationships & { + jobs: { Row: { id: string; code: string; title: string; created_at: string }; Insert: { id?: string; code: string; title: string; created_at?: string }; Update: Partial; @@ -443,20 +430,6 @@ export type Database = { // S: Planstelle. Der Name om_positions stammt aus der Zeit, in der die // alte positions-Tabelle noch danebenstand; sie ist inzwischen weg. om_positions: { - Relationships: [ - { - foreignKeyName: "om_positions_org_unit_id_fkey"; - columns: ["org_unit_id"]; - referencedRelation: "org_units"; - referencedColumns: ["id"]; - }, - { - foreignKeyName: "om_positions_job_id_fkey"; - columns: ["job_id"]; - referencedRelation: "jobs"; - referencedColumns: ["id"]; - }, - ]; Row: { id: string; position_number: string; @@ -481,7 +454,7 @@ export type Database = { }; // Die Onboarding-Checkliste: je Person und Punkt eine Zeile. Welche // Punkte es gibt, steht in lib/onboarding.ts — nicht hier. - onboarding_tasks: NoRelationships & { + onboarding_tasks: { Row: { id: string; employee_id: string; @@ -510,7 +483,7 @@ export type Database = { }; // Die Offboarding-Checkliste — dieselbe Form wie onboarding_tasks, für // den anderen Weg. Punkte in lib/offboarding.ts. - offboarding_tasks: NoRelationships & { + offboarding_tasks: { Row: { id: string; employee_id: string; @@ -539,7 +512,7 @@ export type Database = { }; // Kostenstellen. Die Zuordnung hängt an der Planstelle, nicht an der // Person: der Sitz kostet Geld, auch wenn niemand darauf sitzt. - cost_centers: NoRelationships & { + cost_centers: { Row: { id: string; code: string; @@ -561,7 +534,7 @@ export type Database = { Update: Partial; }; // A011: Planstelle kontiert auf Kostenstelle, zeitabhängig. - position_cost_centers: NoRelationships & { + position_cost_centers: { Row: { id: string; position_id: string; @@ -602,20 +575,6 @@ export type Database = { // Beide Richtungen: über die Planstelle hängt die Verortung in der // Organisation, über die Person die Verortung in der Akte. Die // Einbettung erspart an einem Dutzend Stellen eine zweite Abfrage. - Relationships: [ - { - foreignKeyName: "position_assignments_position_id_fkey"; - columns: ["position_id"]; - referencedRelation: "om_positions"; - referencedColumns: ["id"]; - }, - { - foreignKeyName: "position_assignments_employee_id_fkey"; - columns: ["employee_id"]; - referencedRelation: "employees"; - referencedColumns: ["id"]; - }, - ]; }; }; Views: Record; diff --git a/package.json b/package.json index df5bbc5..4d48d13 100644 --- a/package.json +++ b/package.json @@ -10,18 +10,16 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", - "test:integration": "node --env-file=.env.test.local node_modules/vitest/vitest.mjs run --config vitest.integration.config.ts", - "types:generate": "supabase gen types typescript --local > lib/supabase/types.generated.ts", + "test:integration": "node --env-file-if-exists=.env.test.local node_modules/vitest/vitest.mjs run --config vitest.integration.config.ts", "types:check": "node scripts/check-schema-types.mjs", - "check": "npm run lint && npm run typecheck && npm run test && npm run build" + "check": "npm run lint && npm run typecheck && npm run test && npm run build", + "migrate": "node scripts/migrate.mjs" }, "engines": { "node": ">=22 <25" }, "dependencies": { "@dagrejs/dagre": "^3.0.0", - "@supabase/ssr": "^0.12.3", - "@supabase/supabase-js": "^2.110.8", "@xyflow/react": "^12.11.2", "exceljs": "^4.4.0", "kysely": "^0.29.4", @@ -47,7 +45,6 @@ "eslint-config-next": "^16.2.11", "jsdom": "^29.1.1", "postcss": "^8.5.19", - "supabase": "^2.109.1", "tailwindcss": "^4.3.3", "typescript": "^5.9.3", "vitest": "^4.1.10" diff --git a/supabase/build-org.ts b/scripts/build-org.ts similarity index 100% rename from supabase/build-org.ts rename to scripts/build-org.ts diff --git a/scripts/check-schema-types.mjs b/scripts/check-schema-types.mjs index 3a4b85f..e22f561 100644 --- a/scripts/check-schema-types.mjs +++ b/scripts/check-schema-types.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node -// Guards lib/supabase/types.ts against drifting away from the migrations. +// Guards lib/types.ts against drifting away from the migrations. // // That file is maintained by hand (its own header explains why: no DB -// connection string is available to run `supabase gen types`), so a new +// Werkzeug erzeugt sie), so a new // column reaches the database without the TypeScript side noticing — and // `tsc` stays perfectly happy while the app reads a field that is typed but // absent, or writes one that exists but is not typed. @@ -16,7 +16,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const migrationsDir = join(root, "supabase", "migrations"); +const migrationsDir = join(root, "db", "migrations"); // Columns are compared, not their types: the hand-written file deliberately // uses richer unions than the SQL (`EmploymentStatus` for a text column with @@ -98,9 +98,9 @@ function parseMigrations() { } function parseTypes() { - const source = readFileSync(join(root, "lib", "supabase", "types.ts"), "utf8"); + const source = readFileSync(join(root, "lib", "types.ts"), "utf8"); const start = source.indexOf("Tables: {"); - if (start === -1) throw new Error("Tables block not found in lib/supabase/types.ts"); + if (start === -1) throw new Error("Tables block not found in lib/types.ts"); const tables = new Map(); // Each entry looks like `name: NoRelationships & { Row: { … } … }`; the Row @@ -149,7 +149,7 @@ for (const [name, columns] of tsTables) { } if (problems.length > 0) { - console.error("Schema-Drift zwischen supabase/migrations und lib/supabase/types.ts:\n"); + console.error("Schema-Drift zwischen db/migrations und lib/types.ts:\n"); for (const p of problems.sort()) console.error(" - " + p); console.error(`\n${problems.length} Abweichung(en). types.ts entsprechend nachziehen.`); process.exit(1); diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index 268385a..00e96ae 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -1,23 +1,19 @@ #!/usr/bin/env node // Spielt ausstehende Migrationen ein — und führt Buch darüber. // -// ═══ Warum ein eigener Läufer und nicht `supabase db push` ═══ +// ═══ Warum ein eigener Läufer ═══ // // Zwei Gründe, beide praktisch: // -// 1. **Er braucht nur `pg`.** Das Paket liegt ohnehin im Projekt. Die -// Supabase-CLI müsste im Runner-Container bei jedem Lauf heruntergeladen -// werden, und ihre Version driftet unabhängig von diesem Repository. -// 2. **Er tut genau eine Sache.** `db push` vergleicht Schemata, erzeugt -// Diffs und kann bei einer Abweichung mehr tun als nur die fehlenden -// Dateien anzuwenden. Beim automatischen Ausrollen ist „nur die fehlenden -// Dateien, in Reihenfolge, jede in ihrer eigenen Transaktion" genau das -// gewünschte Verhalten und nichts darüber hinaus. +// 1. **Er braucht nur `pg`.** Das Paket liegt ohnehin im Projekt. Jedes +// zusätzliche Werkzeug müsste im Runner-Container bei jedem Lauf +// heruntergeladen werden, und seine Version driftet unabhängig von +// diesem Repository. +// 2. **Er tut genau eine Sache.** Nur die fehlenden Dateien, in Reihenfolge, +// jede in ihrer eigenen Transaktion. Beim automatischen Ausrollen ist das +// genau das gewünschte Verhalten und nichts darüber hinaus. // -// Die Buchführung liegt bewusst in `supabase_migrations.schema_migrations` — -// derselben Tabelle in derselben Form, die die CLI benutzt. Damit bleibt -// `supabase db push` weiterhin möglich, etwa von der Arbeitsstation aus: es -// sieht dieselben Einträge und überspringt, was hier schon lief. +// Die Buchführung liegt in `migrationen.schema_migrations`. // // ═══ Aufrufe ═══ // @@ -29,14 +25,14 @@ // `--baseline` ist für den einen Fall gedacht, in dem eine Datenbank schon auf // dem Stand ist, die Buchführung aber fehlt — genau die Lage dieses Projekts // im August 2026, weil die Migrationen bis dahin von Hand eingespielt wurden. -// Ohne diesen Schritt hielte der Läufer alle 65 für ausstehend und würde sie +// Ohne diesen Schritt hielte der Läufer alle 67 für ausstehend und würde sie // gegen eine bereits migrierte Datenbank laufen lassen. import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import pg from "pg"; -const VERZEICHNIS = "supabase/migrations"; +const VERZEICHNIS = "db/migrations"; const argumente = new Set(process.argv.slice(2)); const nurZeigen = argumente.has("--dry-run"); @@ -45,13 +41,8 @@ const baseline = argumente.has("--baseline"); // Der **direkte** Zugang als Verwalter — nicht DATABASE_URL. // // DATABASE_URL gehört der Anwendungsrolle, und die darf bewusst kein Schema -// ändern; ausserdem zeigte sie bei Supabase auf den Transaktions-Pooler, der -// mehrteilige Transaktionen nicht mitmacht. -// -// `SUPABASE_DB_URL` heisst nach dem Umzug auf einen eigenen Container nicht -// mehr, was es ist — der Name bleibt als Rückfallebene, damit bestehende -// .env-Dateien und Arbeitsstationen weiterlaufen. -const verbindung = process.env.MIGRATE_DATABASE_URL || process.env.SUPABASE_DB_URL; +// ändern. +const verbindung = process.env.MIGRATE_DATABASE_URL; if (!verbindung) { console.error( "MIGRATE_DATABASE_URL fehlt. Erwartet wird der **direkte** Zugang als Verwalter,\n" + @@ -79,17 +70,39 @@ const client = new pg.Client({ await client.connect(); try { - // Dieselbe Form, die die Supabase-CLI anlegt und erwartet. - await client.query(`create schema if not exists supabase_migrations`); + await client.query(`create schema if not exists migrationen`); await client.query(` - create table if not exists supabase_migrations.schema_migrations ( + create table if not exists migrationen.schema_migrations ( version text primary key, statements text[], name text )`); + // ── Einmalige Übernahme der alten Buchführung ────────────────────── + // + // Bis zum Umzug lagen die Einträge in `supabase_migrations.schema_migrations`. + // Ohne diese Übernahme fände der Läufer eine leere Tabelle vor, hielte alle + // 67 Migrationen für ausstehend und spielte sie gegen eine Datenbank ein, + // die längst auf dem Stand ist — mit einem Fehler beim ersten `create table`, + // das es schon gibt. + // + // Übernommen wird nur in eine noch **leere** Tabelle: ein zweiter Lauf tut + // nichts mehr. Auf einer frischen Datenbank gibt es die alte Tabelle nicht, + // dann fällt die Abfrage ganz weg. + const alteTabelle = ( + await client.query(`select to_regclass('supabase_migrations.schema_migrations') is not null as da`) + ).rows[0]?.da; + if (alteTabelle) { + const { rowCount } = await client.query(` + insert into migrationen.schema_migrations (version, statements, name) + select version, statements, name from supabase_migrations.schema_migrations + where not exists (select 1 from migrationen.schema_migrations) + on conflict (version) do nothing`); + if (rowCount > 0) console.log(`${rowCount} Einträge aus der alten Buchführung übernommen.`); + } + const verbucht = new Set( - (await client.query(`select version from supabase_migrations.schema_migrations`)).rows.map((r) => r.version) + (await client.query(`select version from migrationen.schema_migrations`)).rows.map((r) => r.version) ); const ausstehend = dateien.filter((f) => !verbucht.has(zerlegen(f).version)); @@ -112,7 +125,7 @@ try { const { version, name } = zerlegen(datei); const inhalt = readFileSync(join(VERZEICHNIS, datei), "utf8"); await client.query( - `insert into supabase_migrations.schema_migrations (version, statements, name) + `insert into migrationen.schema_migrations (version, statements, name) values ($1, $2, $3) on conflict (version) do nothing`, [version, [inhalt], name] ); @@ -135,7 +148,7 @@ try { try { await client.query(inhalt); await client.query( - `insert into supabase_migrations.schema_migrations (version, statements, name) values ($1, $2, $3)`, + `insert into migrationen.schema_migrations (version, statements, name) values ($1, $2, $3)`, [version, [inhalt], name] ); await client.query("commit"); diff --git a/scripts/umzug-von-supabase.sh b/scripts/umzug-von-supabase.sh deleted file mode 100644 index aa8061f..0000000 --- a/scripts/umzug-von-supabase.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env bash -# Holt den Datenbestand von Supabase und spielt ihn in den db-Container. -# -# ═══ Der Ablauf, und warum er so aussieht ═══ -# -# Das **Schema** kommt nicht aus dem Abzug, sondern aus den Migrationen: -# scripts/migrate.mjs baut es im Container von Grund auf. Nachgewiesen ist, -# dass dabei Spalte für Spalte, Index für Index, Policy für Policy dasselbe -# herauskommt wie in der Produktion. Der Weg über die Migrationen hat zwei -# Vorteile gegenüber einem Schema-Abzug: die Buchführung stimmt danach von -# selbst, und was Supabase an eigenen Rechten und Eigentümern hineingeschrieben -# hat, kommt gar nicht erst mit. -# -# Übernommen werden nur die **Daten**, mit --disable-triggers: sonst stolpert -# jede Fremdschlüsselprüfung über die Ladereihenfolge. (RLS steht dem nicht im -# Weg — keine der 19 Tabellen hat FORCE ROW LEVEL SECURITY, der Eigentümer -# schreibt also durch.) -# -# Auf dem Host wird **nur Docker** gebraucht: psql und pg_dump kommen aus dem -# Dienst `psql`, Node aus dem Dienst `migrate` — beide im Profil `tools`, das -# bei einem gewöhnlichen `docker compose up` nicht mitläuft. -# -# Aufruf im Deploy-Verzeichnis: -# -# SUPABASE_DB_URL='postgresql://…@…supabase.com:5432/postgres' \ -# ./scripts/umzug-von-supabase.sh -# -# Der Abzug bleibt als Datei liegen und wird nicht gelöscht: geht das -# Einspielen schief, ist der Bestand sonst nur noch bei Supabase. - -set -euo pipefail - -: "${SUPABASE_DB_URL:?SUPABASE_DB_URL fehlt — der direkte Zugang zur alten Datenbank (Supabase: Port 5432)}" - -ABZUG="${ABZUG:-supabase-daten-$(date +%Y%m%d-%H%M%S).sql}" - -echo "── 1/5 Datenbank starten und abwarten" -docker compose up -d db -docker compose ps db - -echo -echo "── 2/5 Schema aus den Migrationen aufbauen" -docker compose run --rm migrate - -echo -echo "── 3/5 Daten von Supabase abziehen → ${ABZUG}" -# Läuft im psql-Abbild, damit pg_dump nicht auf dem Host installiert sein muss. -# --entrypoint überschreibt den psql-Aufruf des Dienstes. -docker compose run --rm --no-deps --entrypoint pg_dump psql \ - "${SUPABASE_DB_URL}" \ - --data-only \ - --disable-triggers \ - --schema=public \ - --no-owner \ - --no-privileges \ - > "${ABZUG}" -echo " $(wc -l < "${ABZUG}") Zeilen, $(du -h "${ABZUG}" | cut -f1)" - -echo -echo "── 4/5 Einspielen" -docker compose run --rm -T psql < "${ABZUG}" - -echo -echo "── 5/5 Gegenprobe — Zeilenzahlen hier und dort" -echo " neue Datenbank:" -docker compose run --rm -T psql -qAt -c " - select ' '||table_name||': '||(xpath('/row/c/text()', - query_to_xml('select count(*) as c from public.'||quote_ident(table_name), false, true, '')))[1]::text - from information_schema.tables - where table_schema='public' and table_type='BASE TABLE' order by table_name" - -echo -echo "Fertig. Nächster Schritt: DATABASE_URL in .env auf den Container zeigen" -echo "lassen (siehe DEPLOYMENT.md), dann docker compose up -d --build" -echo -echo "Der Abzug bleibt unter ${ABZUG} liegen — erst löschen, wenn die Anwendung" -echo "gegen die neue Datenbank nachweislich läuft." diff --git a/supabase/config.toml b/supabase/config.toml deleted file mode 100644 index 61f8aca..0000000 --- a/supabase/config.toml +++ /dev/null @@ -1,33 +0,0 @@ -project_id = "alpenwerk-hr" - -[api] -enabled = true -port = 55321 -schemas = ["public", "graphql_public"] -extra_search_path = ["public"] -max_rows = 1000 - -[db] -port = 55322 -major_version = 15 - -[studio] -enabled = true -port = 55323 - -[inbucket] -enabled = true -port = 55324 - -[analytics] -enabled = false - -[auth] -enabled = true -site_url = "http://127.0.0.1:3000" -additional_redirect_urls = ["http://127.0.0.1:3000"] -jwt_expiry = 3600 -enable_signup = true - -[auth.email] -enable_confirmations = false diff --git a/supabase/functions.sql b/supabase/functions.sql deleted file mode 100644 index dd76632..0000000 --- a/supabase/functions.sql +++ /dev/null @@ -1,555 +0,0 @@ --- Alpenwerk HR — mutation RPCs (Phase 2/3) --- --- One Postgres function per business mutation from the spec --- §4.4/§4.5/§4.6/§4.7. Each function runs as SECURITY INVOKER (the caller's own --- session), so the existing RLS policy on `employees` (hr_admin only) is the --- real authorization gate; the is_hr_admin() check at the top of each function --- just produces a clearer error message than a bare RLS violation. --- --- A single function call is one Postgres transaction: if any statement raises, --- everything in that call rolls back automatically. Run this whole file in the --- Supabase SQL Editor after supabase/schema.sql. - -alter table employee_history add column if not exists reorg_scenario_id uuid references reorg_scenarios(id); - -create or replace function current_actor_name() -returns text language sql stable as $$ - select coalesce(p.full_name, p.email, 'Unbekannt') from profiles p where p.id = auth.uid(); -$$; - -create or replace function require_hr_admin() -returns void language plpgsql as $$ -begin - if not is_hr_admin() then - raise exception 'Nicht berechtigt: nur HR-Admin darf diese Aktion ausführen.'; - end if; -end; -$$; - --- Manager derivation (§2's "reports-to" rule), used by every mutation that --- changes an employee's team/leadership status. -create or replace function resolve_manager_for(p_team_id uuid, p_is_lead boolean, p_division_id uuid) -returns uuid language plpgsql as $$ -declare - v_manager uuid; -begin - if p_team_id is not null and not p_is_lead then - select id into v_manager from employees - where team_id = p_team_id and is_lead = true and status <> 'Ausgetreten' limit 1; - elsif p_team_id is not null and p_is_lead then - select id into v_manager from employees - where division_id = p_division_id and team_id is null and org_level = 1 and status <> 'Ausgetreten' limit 1; - else - select id into v_manager from employees where org_level = 0 and status <> 'Ausgetreten' limit 1; - end if; - return v_manager; -end; -$$; - -create or replace function generate_company_email(p_first_name text, p_last_name text) -returns text language plpgsql as $$ -declare - base text; - candidate text; - n int := 1; - translit text; -begin - translit := lower(p_first_name || '.' || p_last_name); - translit := replace(replace(replace(replace(translit, 'ä','ae'), 'ö','oe'), 'ü','ue'), 'ß','ss'); - base := regexp_replace(translit, '[^a-z0-9.]', '', 'g'); - candidate := base || '@test.manner.at'; - while exists (select 1 from employees where email = candidate) loop - n := n + 1; - candidate := base || n::text || '@test.manner.at'; - end loop; - return candidate; -end; -$$; - --- ── Hire (§4.4) ─────────────────────────────────────────────── -create or replace function hire_employee(payload jsonb) -returns uuid language plpgsql as $$ -declare - v_id uuid; - v_team_id uuid; - v_division_id uuid; - v_position record; - v_job_title text; - v_email text; - v_manager uuid; -begin - perform require_hr_admin(); - - if payload->>'position_id' is not null then - select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open'; - if not found then - raise exception 'Position ist nicht mehr offen.'; - end if; - v_team_id := v_position.team_id; - v_division_id := v_position.division_id; - v_job_title := coalesce(payload->>'job_title', v_position.title); - else - v_team_id := (payload->>'team_id')::uuid; - select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id; - v_job_title := payload->>'job_title'; - end if; - - v_manager := resolve_manager_for(v_team_id, false, v_division_id); - v_email := generate_company_email(payload->>'first_name', payload->>'last_name'); - - insert into employees ( - first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone, - team_id, division_id, job_title, location_id, manager_id, org_level, is_lead, - employment_type, weekly_hours, monthly_salary_gross, contract_type, contract_end_date, - paygrade, source, status, entry_date - ) values ( - payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type, - (payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'), - v_email, payload->>'phone', - v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid, - v_manager, 3, false, - coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'), - coalesce((payload->>'weekly_hours')::numeric, 38.5), - (payload->>'monthly_salary_gross')::numeric, - coalesce((payload->>'contract_type')::contract_type, 'unbefristet'), - nullif(payload->>'contract_end_date', '')::date, - coalesce((payload->>'paygrade')::paygrade_type, 'B'), - coalesce((payload->>'source')::source_type, 'Extern'), - (case when (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status, - (payload->>'entry_date')::date - ) returning id into v_id; - - if payload->>'position_id' is not null then - update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_id - where id = (payload->>'position_id')::uuid; - end if; - - insert into employee_history (employee_id, event_date, event_type, description) - values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title); - - insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) - values (auth.uid(), current_actor_name(), 'Neueinstellung', (payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || (payload->>'entry_date')); - - return v_id; -end; -$$; - --- ── Austritt (§4.5) ─────────────────────────────────────────── -create or replace function terminate_employee(payload jsonb) -returns void language plpgsql as $$ -declare - v_employee_id uuid := (payload->>'employee_id')::uuid; - v_manager uuid; - v_name text; -begin - perform require_hr_admin(); - select manager_id, first_name || ' ' || last_name into v_manager, v_name from employees where id = v_employee_id; - - update employees set manager_id = v_manager where manager_id = v_employee_id and status <> 'Ausgetreten'; - - update employees set - status = 'Ausgetreten', - exit_date = (payload->>'exit_date')::date, - exit_reason = payload->>'exit_reason' - where id = v_employee_id; - - insert into employee_history (employee_id, event_date, event_type, description) - values (v_employee_id, (payload->>'exit_date')::date, 'Austritt', - 'Austritt (' || (payload->>'exit_reason') || ')' || case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end); - - insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) - values (auth.uid(), current_actor_name(), 'Austritt', v_name, v_employee_id, payload->>'exit_reason'); -end; -$$; - --- ── Versetzung (§4.5) ───────────────────────────────────────── -create or replace function transfer_employee(payload jsonb) -returns void language plpgsql as $$ -declare - v_employee_id uuid := (payload->>'employee_id')::uuid; - v_new_team_id uuid := (payload->>'new_team_id')::uuid; - v_division_id uuid; - v_manager uuid; - v_name text; - v_is_lead boolean; -begin - perform require_hr_admin(); - select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_new_team_id; - select is_lead, first_name || ' ' || last_name into v_is_lead, v_name from employees where id = v_employee_id; - v_manager := resolve_manager_for(v_new_team_id, v_is_lead, v_division_id); - - update employees set - team_id = v_new_team_id, - job_title = coalesce(nullif(payload->>'new_title', ''), job_title), - manager_id = v_manager - where id = v_employee_id; - - insert into employee_history (employee_id, event_date, event_type, description) - values (v_employee_id, (payload->>'effective_date')::date, 'Versetzung', - 'Versetzung, wirksam ab ' || (payload->>'effective_date') || - case when payload->>'new_title' is not null and payload->>'new_title' <> '' then ', neue Position: ' || (payload->>'new_title') else '' end); - - insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) - values (auth.uid(), current_actor_name(), 'Versetzung', v_name, v_employee_id, 'Wirksam ab ' || (payload->>'effective_date')); -end; -$$; - --- ── Beförderung (§4.5) ──────────────────────────────────────── -create or replace function promote_employee(payload jsonb) -returns void language plpgsql as $$ -declare - v_employee_id uuid := (payload->>'employee_id')::uuid; - v_old_paygrade paygrade_type; - v_name text; - v_details text; -begin - perform require_hr_admin(); - select paygrade, first_name || ' ' || last_name into v_old_paygrade, v_name from employees where id = v_employee_id; - - update employees set - job_title = payload->>'new_title', - monthly_salary_gross = (payload->>'new_salary')::numeric, - paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade) - where id = v_employee_id; - - v_details := 'Neue Position: ' || (payload->>'new_title'); - if payload->>'new_paygrade' is not null and (payload->>'new_paygrade')::paygrade_type <> v_old_paygrade then - v_details := v_details || ', neue Paygrade: ' || (payload->>'new_paygrade'); - end if; - - insert into employee_history (employee_id, event_date, event_type, description) - values (v_employee_id, (payload->>'effective_date')::date, 'Beförderung', v_details); - - insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) - values (auth.uid(), current_actor_name(), 'Beförderung', v_name, v_employee_id, v_details); -end; -$$; - --- ── Karenz verwalten (§4.5) — adjust return date or record actual return ── -create or replace function adjust_karenz_return(payload jsonb) -returns void language plpgsql as $$ -declare - v_employee_id uuid := (payload->>'employee_id')::uuid; - v_name text; -begin - perform require_hr_admin(); - select first_name || ' ' || last_name into v_name from employees where id = v_employee_id; - - update employees set karenz_return_date = (payload->>'new_return_date')::date where id = v_employee_id; - - insert into employee_history (employee_id, event_date, event_type, description) - values (v_employee_id, current_date, 'Karenz', - 'Rückkehrdatum angepasst auf ' || (payload->>'new_return_date') || - case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end); - - insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) - values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Neues Rückkehrdatum: ' || (payload->>'new_return_date')); -end; -$$; - -create or replace function record_karenz_return(payload jsonb) -returns void language plpgsql as $$ -declare - v_employee_id uuid := (payload->>'employee_id')::uuid; - v_return_date date := (payload->>'return_date')::date; - v_name text; - v_team_id uuid; - v_division_id uuid; - v_is_lead boolean; - v_manager uuid; - v_employment_type employment_type; - v_weekly_hours numeric; -begin - perform require_hr_admin(); - select first_name || ' ' || last_name, team_id, division_id, is_lead - into v_name, v_team_id, v_division_id, v_is_lead - from employees where id = v_employee_id; - - if payload->>'employment_mode' = 'Vollzeit' then - v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5; - elsif payload->>'employment_mode' = 'Teilzeit' then - v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric; - end if; - - if v_return_date <= current_date then - v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id); - update employees set - status = 'Aktiv', - karenz_return_date = null, - manager_id = v_manager, - employment_type = coalesce(v_employment_type, employment_type), - weekly_hours = coalesce(v_weekly_hours, weekly_hours) - where id = v_employee_id; - else - update employees set karenz_return_date = v_return_date, - employment_type = coalesce(v_employment_type, employment_type), - weekly_hours = coalesce(v_weekly_hours, weekly_hours) - where id = v_employee_id; - end if; - - insert into employee_history (employee_id, event_date, event_type, description) - values (v_employee_id, v_return_date, 'Rückkehr', 'Wiedereintritt aus Karenz am ' || (payload->>'return_date')); - - insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) - values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || (payload->>'return_date')); -end; -$$; - --- ── Daten ändern (§4.5) — diffs person vs. contract fields ──── -create or replace function change_employee_data(payload jsonb) -returns void language plpgsql as $$ -declare - v_employee_id uuid := (payload->>'employee_id')::uuid; - v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date); - v_old employees%rowtype; - v_name text; - v_person_changes text[] := '{}'; - v_contract_changes text[] := '{}'; - v_person jsonb := payload->'person'; - v_contract jsonb := payload->'contract'; -begin - perform require_hr_admin(); - select * into v_old from employees where id = v_employee_id; - v_name := v_old.first_name || ' ' || v_old.last_name; - - if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if; - if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if; - if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if; - if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if; - if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if; - if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if; - if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if; - if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if; - if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if; - if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if; - - if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if; - if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if; - if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if; - if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if; - - update employees set - first_name = coalesce(v_person->>'first_name', first_name), - last_name = coalesce(v_person->>'last_name', last_name), - gender = coalesce((v_person->>'gender')::gender_type, gender), - birth_date = coalesce((v_person->>'birth_date')::date, birth_date), - sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer), - nationality = coalesce(v_person->>'nationality', nationality), - address = coalesce(v_person->>'address', address), - address_country = coalesce(v_person->>'address_country', address_country), - email = coalesce(v_person->>'email', email), - phone = coalesce(v_person->>'phone', phone), - employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type), - weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours), - contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type), - contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end - where id = v_employee_id; - - if array_length(v_person_changes, 1) > 0 then - insert into employee_history (employee_id, event_date, event_type, description) - values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date); - insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) - values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date); - end if; - - if array_length(v_contract_changes, 1) > 0 then - insert into employee_history (employee_id, event_date, event_type, description) - values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date); - insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) - values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date); - end if; -end; -$$; - --- ── Wiedereinstellung (§4.5) ────────────────────────────────── -create or replace function rehire_employee(payload jsonb) -returns void language plpgsql as $$ -declare - v_employee_id uuid := (payload->>'employee_id')::uuid; - v_rehire_date date := (payload->>'rehire_date')::date; - v_team_id uuid; - v_division_id uuid; - v_is_lead boolean; - v_manager uuid; - v_name text; -begin - perform require_hr_admin(); - select team_id, division_id, is_lead, first_name || ' ' || last_name - into v_team_id, v_division_id, v_is_lead, v_name - from employees where id = v_employee_id; - v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id); - - update employees set - status = (case when v_rehire_date > current_date then 'Geplant' else 'Aktiv' end)::employment_status, - entry_date = v_rehire_date, - exit_date = null, - exit_reason = null, - manager_id = v_manager - where id = v_employee_id; - - insert into employee_history (employee_id, event_date, event_type, description) - values (v_employee_id, v_rehire_date, 'Wiedereintritt', 'Wiedereinstellung zum ' || (payload->>'rehire_date')); - - insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) - values (auth.uid(), current_actor_name(), 'Wiedereinstellung', v_name, v_employee_id, 'Wiedereintritt am ' || (payload->>'rehire_date')); -end; -$$; - --- ── Position ausschreiben (§4.6) ────────────────────────────── -create or replace function create_position(payload jsonb) -returns uuid language plpgsql as $$ -declare - v_id uuid; - v_superior_id uuid := (payload->>'superior_employee_id')::uuid; - v_is_lead boolean := coalesce((payload->>'is_lead')::boolean, false); - v_team_id uuid; -begin - perform require_hr_admin(); - - if v_is_lead then - v_team_id := (payload->>'team_id')::uuid; - else - select team_id into v_team_id from employees where id = v_superior_id; - end if; - - insert into positions (title, team_id, is_lead, reports_to_employee_id) - values (payload->>'title', v_team_id, v_is_lead, v_superior_id) - returning id into v_id; - - insert into audit_log (actor_user_id, actor_name, action, target_label, details) - values (auth.uid(), current_actor_name(), 'Ausschreibung', payload->>'title', 'Position ausgeschrieben'); - - return v_id; -end; -$$; - --- ── Intern besetzen (§4.6) ──────────────────────────────────── -create or replace function staff_position_internally(payload jsonb) -returns void language plpgsql as $$ -declare - v_position record; - v_employee_id uuid := (payload->>'employee_id')::uuid; - v_manager uuid; - v_name text; -begin - perform require_hr_admin(); - select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open'; - if not found then - raise exception 'Position ist nicht mehr offen.'; - end if; - select first_name || ' ' || last_name into v_name from employees where id = v_employee_id; - - v_manager := resolve_manager_for(v_position.team_id, v_position.is_lead, v_position.division_id); - - update employees set - team_id = v_position.team_id, - job_title = v_position.title, - source = 'Intern', - is_lead = case when v_position.is_lead then true else is_lead end, - org_level = case when v_position.is_lead then 2 else org_level end, - manager_id = v_manager - where id = v_employee_id; - - if v_position.is_lead then - update employees set manager_id = v_employee_id - where team_id = v_position.team_id and id <> v_employee_id and is_lead = false and status <> 'Ausgetreten'; - end if; - - update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_employee_id - where id = v_position.id; - - insert into employee_history (employee_id, event_date, event_type, description) - values (v_employee_id, current_date, 'Versetzung', 'Interne Besetzung: ' || v_position.title); - - insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) - values (auth.uid(), current_actor_name(), 'Interne Besetzung', v_name, v_employee_id, v_position.title); -end; -$$; - --- ── Reorganisation (§4.7) ───────────────────────────────────── --- payload: { name, effective_date, moves: [{ kind, label, employee_ids: uuid[], target_team_id }] } -create or replace function apply_reorg(payload jsonb) -returns uuid language plpgsql as $$ -declare - v_scenario_id uuid; - v_move jsonb; - v_employee_id text; - v_target_team_id uuid; - v_division_id uuid; - v_is_lead boolean; - v_manager uuid; - v_snapshot jsonb := '{}'::jsonb; - v_old record; - v_total_moves int := 0; -begin - perform require_hr_admin(); - - insert into reorg_scenarios (name, effective_date, created_by, applied, applied_at) - values (payload->>'name', (payload->>'effective_date')::date, auth.uid(), true, now()) - returning id into v_scenario_id; - - for v_move in select * from jsonb_array_elements(payload->'moves') - loop - v_target_team_id := (v_move->>'target_team_id')::uuid; - select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_target_team_id; - - insert into reorg_moves (scenario_id, kind, payload) values (v_scenario_id, v_move->>'kind', v_move); - - for v_employee_id in select jsonb_array_elements_text(v_move->'employee_ids') - loop - select * into v_old from employees where id = v_employee_id::uuid; - v_snapshot := v_snapshot || jsonb_build_object(v_employee_id, jsonb_build_object( - 'team_id', v_old.team_id, 'division_id', v_old.division_id, 'manager_id', v_old.manager_id - )); - - v_is_lead := v_old.is_lead; - v_manager := resolve_manager_for(v_target_team_id, v_is_lead, v_division_id); - - update employees set team_id = v_target_team_id, manager_id = v_manager where id = v_employee_id::uuid; - - insert into employee_history (employee_id, event_date, event_type, description, reorg_scenario_id) - values (v_employee_id::uuid, (payload->>'effective_date')::date, 'Reorganisation', - 'Reorganisation "' || (payload->>'name') || '": neues Team zugewiesen', v_scenario_id); - - v_total_moves := v_total_moves + 1; - end loop; - end loop; - - update reorg_scenarios set undo_snapshot = v_snapshot where id = v_scenario_id; - - insert into audit_log (actor_user_id, actor_name, action, target_label, details) - values (auth.uid(), current_actor_name(), 'Reorganisation', payload->>'name', v_total_moves || ' Mitarbeiter:innen betroffen'); - - return v_scenario_id; -end; -$$; - -create or replace function undo_reorg(payload jsonb) -returns void language plpgsql as $$ -declare - v_scenario record; - v_key text; - v_val jsonb; -begin - perform require_hr_admin(); - select * into v_scenario from reorg_scenarios where id = (payload->>'scenario_id')::uuid and applied = true; - if not found or v_scenario.undo_snapshot is null then - raise exception 'Reorganisation kann nicht rückgängig gemacht werden (kein Snapshot vorhanden).'; - end if; - - for v_key, v_val in select * from jsonb_each(v_scenario.undo_snapshot) - loop - update employees set - team_id = nullif(v_val->>'team_id','')::uuid, - division_id = (v_val->>'division_id')::uuid, - manager_id = nullif(v_val->>'manager_id','')::uuid - where id = v_key::uuid; - end loop; - - delete from employee_history where reorg_scenario_id = v_scenario.id; - update reorg_scenarios set applied = false where id = v_scenario.id; - - insert into audit_log (actor_user_id, actor_name, action, target_label, details) - values (auth.uid(), current_actor_name(), 'Reorganisation rückgängig', v_scenario.name, 'Reorganisation zurückgesetzt'); -end; -$$; diff --git a/supabase/functions_2.sql b/supabase/functions_2.sql deleted file mode 100644 index c750cf6..0000000 --- a/supabase/functions_2.sql +++ /dev/null @@ -1,29 +0,0 @@ --- Addendum to supabase/functions.sql — run after that file. --- --- The spec's "Karenz verwalten" panel (§4.5) only covers employees already on --- Karenz (adjust return date / record return). It doesn't specify the fields --- for *starting* a Karenz period from Aktiv, even though §4.3 clearly shows a --- "Karenz" button for that case. This fills that gap with a reasonable, --- minimal form: start date + planned return date + optional note. - -create or replace function start_karenz(payload jsonb) -returns void language plpgsql as $$ -declare - v_employee_id uuid := (payload->>'employee_id')::uuid; - v_name text; -begin - perform require_hr_admin(); - select first_name || ' ' || last_name into v_name from employees where id = v_employee_id; - - update employees set status = 'Karenz', karenz_return_date = (payload->>'planned_return_date')::date - where id = v_employee_id; - - insert into employee_history (employee_id, event_date, event_type, description) - values (v_employee_id, (payload->>'karenz_start_date')::date, 'Karenz', - 'Karenzantritt, geplante Rückkehr am ' || (payload->>'planned_return_date') || - case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end); - - insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) - values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Karenzantritt, geplante Rückkehr ' || (payload->>'planned_return_date')); -end; -$$; diff --git a/supabase/functions_3.sql b/supabase/functions_3.sql deleted file mode 100644 index cf90cee..0000000 --- a/supabase/functions_3.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Addendum to supabase/schema.sql + functions.sql — run after those. --- --- employee_history intentionally has no UPDATE/DELETE policy (§4.9's --- "unveraenderbar" / append-only requirement). But undo_reorg needs to --- remove the specific history rows a reorg created — found via live --- testing: the DELETE inside undo_reorg silently matched 0 rows under RLS --- (no error, since RLS just filters DELETE-eligible rows to none), leaving --- Reorganisation entries behind after an otherwise-successful undo. --- --- Scope the exception as narrowly as possible: hr_admin may delete a --- history row only if it carries a reorg_scenario_id, i.e. only rows --- apply_reorg created. Eintritt/Austritt/Beförderung/etc. rows (always --- reorg_scenario_id IS NULL) remain fully immutable. -create policy "history_delete_admin_reorg_undo" on employee_history for delete - using (is_hr_admin() and reorg_scenario_id is not null); diff --git a/supabase/functions_4.sql b/supabase/functions_4.sql deleted file mode 100644 index 65495c6..0000000 --- a/supabase/functions_4.sql +++ /dev/null @@ -1,71 +0,0 @@ --- Addendum to supabase/functions.sql — run after that file (and functions_2/3.sql). --- --- "Daten ändern" had no "Wirksam ab" field, unlike Versetzung/Beförderung/ --- Karenz — every change was silently logged with today's date regardless --- of when it should actually take effect. Adds an effective_date input --- (defaults to today if omitted) used for both the history event_date and --- noted in the change description. -create or replace function change_employee_data(payload jsonb) -returns void language plpgsql as $$ -declare - v_employee_id uuid := (payload->>'employee_id')::uuid; - v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date); - v_old employees%rowtype; - v_name text; - v_person_changes text[] := '{}'; - v_contract_changes text[] := '{}'; - v_person jsonb := payload->'person'; - v_contract jsonb := payload->'contract'; -begin - perform require_hr_admin(); - select * into v_old from employees where id = v_employee_id; - v_name := v_old.first_name || ' ' || v_old.last_name; - - if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if; - if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if; - if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if; - if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if; - if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if; - if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if; - if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if; - if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if; - if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if; - if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if; - - if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if; - if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if; - if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if; - if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if; - - update employees set - first_name = coalesce(v_person->>'first_name', first_name), - last_name = coalesce(v_person->>'last_name', last_name), - gender = coalesce((v_person->>'gender')::gender_type, gender), - birth_date = coalesce((v_person->>'birth_date')::date, birth_date), - sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer), - nationality = coalesce(v_person->>'nationality', nationality), - address = coalesce(v_person->>'address', address), - address_country = coalesce(v_person->>'address_country', address_country), - email = coalesce(v_person->>'email', email), - phone = coalesce(v_person->>'phone', phone), - employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type), - weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours), - contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type), - contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end - where id = v_employee_id; - - if array_length(v_person_changes, 1) > 0 then - insert into employee_history (employee_id, event_date, event_type, description) - values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date); - insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) - values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date); - end if; - - if array_length(v_contract_changes, 1) > 0 then - insert into employee_history (employee_id, event_date, event_type, description) - values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date); - insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) - values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date); - end if; -end; -$$; diff --git a/supabase/schema.sql b/supabase/schema.sql deleted file mode 100644 index d7bbe28..0000000 --- a/supabase/schema.sql +++ /dev/null @@ -1,360 +0,0 @@ --- Alpenwerk HR — initial schema --- --- Based on spec §3, with the following corrections --- (see the Phase 1 plan for the full rationale): --- - gender_type restricted to m/w (spec's domain rules exclude "divers") --- - employees.location replaced by a locations reference table + location_id FK --- (the spec's own CHECK listed Wien/Linz/Graz, which contradicts §2's real site list) --- - nationality constrained to the picklist named in §2 --- - added: locations, profiles (role-based access), employees_directory (salary-masked view) --- - added: address/address_country/contract_end_date columns (required by §4.5's "Daten ändern" --- panel and the befristet/"Befristet bis" rule, but missing from §3's literal table) --- - added: 'Stammdatenänderung' history event type (required by §4.5, missing from §3's enum) --- - added: reorg_scenarios.undo_snapshot jsonb (required by §4.7's undo feature) --- - added: position number generation + org-unit auto-derivation as real functions/triggers - -create extension if not exists "pgcrypto"; - --- ── Org units ──────────────────────────────────────────────── -create table divisions ( -- "Bereich", numbers 20xxxxxx - id uuid primary key default gen_random_uuid(), - org_number text not null unique check (org_number ~ '^20\d{6}$'), - name text not null unique -); - -create table departments ( -- "Abteilung", numbers 21xxxxxx - id uuid primary key default gen_random_uuid(), - org_number text not null unique check (org_number ~ '^21\d{6}$'), - name text not null, - division_id uuid not null references divisions(id) -); - -create table teams ( -- "Team", numbers 22xxxxxx - id uuid primary key default gen_random_uuid(), - org_number text not null unique check (org_number ~ '^22\d{6}$'), - name text not null, - department_id uuid not null references departments(id) -); - --- ── Locations (site ties to a country; country picklist drives the UI's --- "select country auto-selects its locations" rule from §2) ───────── -create table locations ( - id uuid primary key default gen_random_uuid(), - name text not null unique, -- Wien-Hernals, Wolkersdorf, Köln, Brünn, Ljubljana - country text not null check (country in ('Österreich', 'Deutschland', 'Tschechien', 'Slowenien')) -); - --- ── Profiles (role-based access per §6) ───────────────────── -create table profiles ( - id uuid primary key references auth.users(id) on delete cascade, - email text not null, - full_name text, - role text not null default 'manager' check (role in ('hr_admin', 'manager')), - created_at timestamptz not null default now() -); - --- ── Employees ──────────────────────────────────────────────── -create type employment_status as enum ('Aktiv', 'Karenz', 'Geplant', 'Ausgetreten'); -create type employment_type as enum ('Vollzeit', 'Teilzeit'); -create type contract_type as enum ('unbefristet', 'befristet'); -create type paygrade_type as enum ('A', 'B', 'C', 'D', 'E', 'F'); -create type source_type as enum ('Intern', 'Extern'); -create type gender_type as enum ('m', 'w'); - -create table employees ( - id uuid primary key default gen_random_uuid(), - personnel_number int generated always as identity (start with 1001), -- Pers.-Nr. - first_name text not null, - last_name text not null, - gender gender_type not null, - birth_date date not null, - sv_nummer text, - nationality text not null default 'Österreich' check (nationality in ( - 'Österreich', 'Deutschland', 'Tschechien', 'Slowenien', 'Türkei', - 'Serbien', 'Kroatien', 'Bosnien', 'Ungarn', 'Andere' - )), - address text, - address_country text check (address_country in ('Österreich', 'Deutschland', 'Tschechien', 'Slowenien', 'Andere')), - email text not null unique, - phone text, - team_id uuid references teams(id), -- nullable only for CEO / division heads without a team - division_id uuid not null references divisions(id), -- auto-derived from team_id by trigger when team_id is set - job_title text not null, - location_id uuid not null references locations(id), - manager_id uuid references employees(id), - org_level int not null default 3 check (org_level between 0 and 3), -- 0=CEO,1=division head,2=team lead,3=IC - is_lead boolean not null default false, - employment_type employment_type not null default 'Vollzeit', - weekly_hours numeric(4,1) not null default 38.5, - monthly_salary_gross numeric(10,2) not null check (monthly_salary_gross > 0), -- 14x/year convention - contract_type contract_type not null default 'unbefristet', - contract_end_date date, - paygrade paygrade_type not null default 'B', - source source_type not null default 'Extern', - status employment_status not null default 'Aktiv', - entry_date date not null, - exit_date date, - exit_reason text, - karenz_return_date date, - avatar_color text, -- hex, for initials badge; app falls back to a deterministic hash if null - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - constraint chk_exit_after_entry check (exit_date is null or exit_date >= entry_date), - constraint chk_karenz_return_after_entry check (karenz_return_date is null or karenz_return_date >= entry_date), - constraint chk_befristet_end check (contract_type <> 'befristet' or contract_end_date is not null), - constraint chk_weekly_hours check ( - (employment_type = 'Vollzeit' and weekly_hours = 38.5) or - (employment_type = 'Teilzeit' and weekly_hours > 0 and weekly_hours < 38.5) - ) -); -create index on employees (team_id); -create index on employees (division_id); -create index on employees (manager_id); -create index on employees (status); - --- ── Employee history (append-only audit trail per person) ─── -create type history_event_type as enum ( - 'Eintritt', 'Beförderung', 'Versetzung', 'Karenz', 'Vertragsänderung', 'Stammdatenänderung', - 'Austritt', 'Wiedereintritt', 'Reorganisation', 'Gehaltsanpassung', 'Rückkehr' -); -create table employee_history ( - id uuid primary key default gen_random_uuid(), - employee_id uuid not null references employees(id) on delete cascade, - event_date date not null, - event_type history_event_type not null, - description text not null, - created_at timestamptz not null default now() -); -create index on employee_history (employee_id, event_date desc); - --- ── Positions (Planstellen) ────────────────────────────────── -create table positions ( - id uuid primary key default gen_random_uuid(), - position_number text not null unique check (position_number ~ '^6\d{7}$'), - title text not null, - team_id uuid not null references teams(id), - division_id uuid not null references divisions(id), -- auto-derived from team_id by trigger - is_lead boolean not null default false, - reports_to_employee_id uuid references employees(id), -- the superior manager chosen at creation - status text not null default 'open' check (status in ('open', 'filled')), - created_at timestamptz not null default now(), - filled_at timestamptz, - filled_by_employee_id uuid references employees(id) -); -create index on positions (team_id); -create index on positions (status); - --- ── Hire drafts (resumable wizard state) ───────────────────── -create table hire_drafts ( - id uuid primary key default gen_random_uuid(), - created_by uuid references auth.users(id), - step int not null default 0, - payload jsonb not null, -- full wizard form state - updated_at timestamptz not null default now() -); - --- ── Saved reports ──────────────────────────────────────────── -create table saved_reports ( - id uuid primary key default gen_random_uuid(), - created_by uuid references auth.users(id), - name text not null, - config jsonb not null, -- { measure, group, split, filters... } - created_at timestamptz not null default now() -); - --- ── Audit log (system-wide, immutable) ─────────────────────── -create table audit_log ( - id uuid primary key default gen_random_uuid(), - occurred_at timestamptz not null default now(), - actor_user_id uuid references auth.users(id), - actor_name text not null, - action text not null, -- e.g. 'Neueinstellung','Austritt','Versetzung','Beförderung','Karenz', - -- 'Vertragsänderung','Stammdatenänderung','Wiedereinstellung','Ausschreibung', - -- 'Interne Besetzung','Reorganisation','Reorganisation rückgängig','Rückkehr', - -- 'Gehaltsanpassung' - target_label text not null, -- human-readable name of what changed - target_employee_id uuid references employees(id), - details text -); -create index on audit_log (occurred_at desc); - --- ── Reorg scenarios (persistence of in-progress/applied reorg plans) ─ -create table reorg_scenarios ( - id uuid primary key default gen_random_uuid(), - name text not null, - effective_date date not null, - created_by uuid references auth.users(id), - applied boolean not null default false, - applied_at timestamptz, - undo_snapshot jsonb, -- pre-change employee state + history/audit high-water marks, for undo - created_at timestamptz not null default now() -); -create table reorg_moves ( - id uuid primary key default gen_random_uuid(), - scenario_id uuid not null references reorg_scenarios(id) on delete cascade, - kind text not null check (kind in ('emp', 'team', 'abt', 'dept')), - payload jsonb not null -- employee ids / team id / dept id / target division, counts, labels -); - --- ── Functions & triggers ───────────────────────────────────── - --- Auto-derive division_id from team_id (keeps the denormalized division in sync --- with the team's real parent chain; §3's closing instruction). -create or replace function fn_set_employee_org_unit() -returns trigger -language plpgsql -as $$ -begin - if new.team_id is not null then - select dep.division_id into new.division_id - from teams t - join departments dep on dep.id = t.department_id - where t.id = new.team_id; - end if; - return new; -end; -$$; - -create trigger trg_employees_set_org_unit -before insert or update of team_id on employees -for each row execute function fn_set_employee_org_unit(); - -create or replace function fn_set_position_org_unit() -returns trigger -language plpgsql -as $$ -begin - select dep.division_id into new.division_id - from teams t - join departments dep on dep.id = t.department_id - where t.id = new.team_id; - return new; -end; -$$; - -create trigger trg_positions_set_org_unit -before insert or update of team_id on positions -for each row execute function fn_set_position_org_unit(); - -create or replace function fn_touch_updated_at() -returns trigger -language plpgsql -as $$ -begin - new.updated_at = now(); - return new; -end; -$$; - -create trigger trg_employees_touch_updated_at -before update on employees -for each row execute function fn_touch_updated_at(); - --- Unique 8-digit position numbers starting with '6' (§2). -create or replace function generate_position_number() -returns text -language plpgsql -as $$ -declare - candidate text; -begin - loop - candidate := '6' || lpad(floor(random() * 10000000)::text, 7, '0'); - exit when not exists (select 1 from positions where position_number = candidate); - end loop; - return candidate; -end; -$$; - -alter table positions alter column position_number set default generate_position_number(); - --- ── Role helper (SECURITY DEFINER avoids RLS recursion on profiles) ── -create or replace function is_hr_admin() -returns boolean -language sql -security definer -set search_path = public -stable -as $$ - select exists ( - select 1 from profiles p where p.id = auth.uid() and p.role = 'hr_admin' - ); -$$; - --- ── Salary-masked read view for the manager role (§6) ──────── --- Owned by the migration role (postgres), which bypasses RLS on the base --- table, so this view is reachable by both roles while column-masking --- salary per session via is_hr_admin(). -create view employees_directory as -select - e.id, e.personnel_number, e.first_name, e.last_name, e.gender, e.birth_date, e.sv_nummer, - e.nationality, e.address, e.address_country, e.email, e.phone, e.team_id, e.division_id, - e.job_title, e.location_id, e.manager_id, e.org_level, e.is_lead, e.employment_type, - e.weekly_hours, - case when is_hr_admin() then e.monthly_salary_gross else null end as monthly_salary_gross, - e.contract_type, e.contract_end_date, e.paygrade, e.source, e.status, e.entry_date, e.exit_date, - e.exit_reason, e.karenz_return_date, e.avatar_color, e.created_at, e.updated_at -from employees e; - -grant select on employees_directory to authenticated; - --- ── Row Level Security ─────────────────────────────────────── -alter table divisions enable row level security; -alter table departments enable row level security; -alter table teams enable row level security; -alter table locations enable row level security; -alter table profiles enable row level security; -alter table employees enable row level security; -alter table employee_history enable row level security; -alter table positions enable row level security; -alter table hire_drafts enable row level security; -alter table saved_reports enable row level security; -alter table audit_log enable row level security; -alter table reorg_scenarios enable row level security; -alter table reorg_moves enable row level security; - --- Org reference data: readable by any authenticated user, writable by hr_admin only. -create policy "org_read" on divisions for select using (auth.role() = 'authenticated'); -create policy "org_write" on divisions for all using (is_hr_admin()) with check (is_hr_admin()); -create policy "org_read" on departments for select using (auth.role() = 'authenticated'); -create policy "org_write" on departments for all using (is_hr_admin()) with check (is_hr_admin()); -create policy "org_read" on teams for select using (auth.role() = 'authenticated'); -create policy "org_write" on teams for all using (is_hr_admin()) with check (is_hr_admin()); -create policy "org_read" on locations for select using (auth.role() = 'authenticated'); -create policy "org_write" on locations for all using (is_hr_admin()) with check (is_hr_admin()); - --- Profiles: users read their own row; hr_admin reads/writes all. -create policy "profiles_select_own" on profiles for select using (auth.uid() = id); -create policy "profiles_select_admin" on profiles for select using (is_hr_admin()); -create policy "profiles_write_admin" on profiles for insert with check (is_hr_admin()); -create policy "profiles_update_admin" on profiles for update using (is_hr_admin()) with check (is_hr_admin()); - --- Employees: only hr_admin reads/writes the base table directly. The manager --- role reads through employees_directory instead (salary masked there). -create policy "employees_admin_all" on employees for all using (is_hr_admin()) with check (is_hr_admin()); - --- Employee history: any authenticated user can read; only hr_admin can append; immutable otherwise. -create policy "history_read" on employee_history for select using (auth.role() = 'authenticated'); -create policy "history_insert_admin" on employee_history for insert with check (is_hr_admin()); - --- Positions: any authenticated user can browse open positions; hr_admin manages them. -create policy "positions_read" on positions for select using (auth.role() = 'authenticated'); -create policy "positions_write_admin" on positions for all using (is_hr_admin()) with check (is_hr_admin()); - --- Hire drafts: scoped to their creator. -create policy "hire_drafts_owner" on hire_drafts for all - using (created_by = auth.uid()) with check (created_by = auth.uid()); - --- Saved reports: scoped to their creator. -create policy "saved_reports_owner" on saved_reports for all - using (created_by = auth.uid()) with check (created_by = auth.uid()); - --- Audit log: any authenticated user can read; only hr_admin can append; immutable (no update/delete policy). -create policy "audit_read" on audit_log for select using (auth.role() = 'authenticated'); -create policy "audit_insert_admin" on audit_log for insert with check (is_hr_admin()); - --- Reorg scenarios/moves: any authenticated user can see the (small) recent list; hr_admin manages them. -create policy "reorg_scenarios_read" on reorg_scenarios for select using (auth.role() = 'authenticated'); -create policy "reorg_scenarios_write_admin" on reorg_scenarios for all using (is_hr_admin()) with check (is_hr_admin()); -create policy "reorg_moves_read" on reorg_moves for select using (auth.role() = 'authenticated'); -create policy "reorg_moves_write_admin" on reorg_moves for all using (is_hr_admin()) with check (is_hr_admin()); diff --git a/supabase/schema_2.sql b/supabase/schema_2.sql deleted file mode 100644 index eb51022..0000000 --- a/supabase/schema_2.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Addendum to supabase/schema.sql — run after that file. --- --- Staatsbürgerschaft and Wohnland now use a searchable picker over the --- full UN member states list (193 countries, see lib/countries.ts) --- instead of the original ~9/5-value picklists. The old CHECK constraints --- would reject nearly all of those values, so they're dropped here. The --- app is the source of truth for valid values (same approach the rest of --- the app already relies on for large open-ended pickers); the columns --- stay plain text (nationality keeps its NOT NULL). -alter table employees drop constraint if exists employees_nationality_check; -alter table employees drop constraint if exists employees_address_country_check; diff --git a/supabase/seed.ts b/supabase/seed.ts deleted file mode 100644 index 3619f79..0000000 --- a/supabase/seed.ts +++ /dev/null @@ -1,956 +0,0 @@ -// Seeds ~800 realistic Austrian/DACH employees + org structure + a handful of -// open positions, per spec §5. -// -// Run with: node --env-file=.env.local supabase/seed.ts -// Uses the service-role key over the Supabase REST API (bypasses RLS) — no -// direct Postgres connection needed. All row ids are generated client-side -// so parent/child references never require a round-trip. - -import { createClient } from "@supabase/supabase-js"; -import { randomUUID } from "node:crypto"; -// Explicit .ts extension: this file is run directly by Node (type-stripping, -// ESM), where an extensionless relative import does not resolve. -import { svnrCheckDigit, svnrErrorMessage, validateSvnr } from "../lib/svnr.ts"; -import { ABSENCE_TYPES } from "../lib/absence.ts"; -import { buildOrg, type BuiltUnit, type DivisionDef } from "./build-org.ts"; - -const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL; -const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; -if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { - throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in the environment"); -} - -const ADMIN_EMAIL = "m.stubhan@loudspring.at"; - -const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { - auth: { autoRefreshToken: false, persistSession: false }, -}); - -// ── RNG helpers ────────────────────────────────────────────── -function randInt(min: number, max: number): number { - return Math.floor(Math.random() * (max - min + 1)) + min; -} -function pick(arr: readonly T[]): T { - return arr[randInt(0, arr.length - 1)]; -} -function chance(probability: number): boolean { - return Math.random() < probability; -} -function weightedPick(entries: readonly (readonly [T, number])[]): T { - const total = entries.reduce((sum, [, w]) => sum + w, 0); - let r = Math.random() * total; - for (const [value, w] of entries) { - r -= w; - if (r <= 0) return value; - } - return entries[entries.length - 1][0]; -} -function addDays(d: Date, days: number): Date { - const r = new Date(d); - r.setDate(r.getDate() + days); - return r; -} -// Bewusst *nicht* über toISOString(): alle Daten hier entstehen aus lokalen -// Bestandteilen (new Date(jahr, monat, tag), addDays), und toISOString rechnet -// nach UTC um. In Österreich verschiebt das jedes Datum um einen Tag nach -// hinten — womit das gespeicherte Geburtsdatum nicht mehr zu dem passt, das -// makeSvNummer aus denselben lokalen Bestandteilen in die SV-Nummer schreibt. -function isoDate(d: Date): string { - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${d.getFullYear()}-${m}-${day}`; -} -function randomDateBetween(start: Date, end: Date): Date { - const t = start.getTime() + Math.random() * (end.getTime() - start.getTime()); - return new Date(t); -} -function slugify(s: string): string { - return s - .toLowerCase() - .replace(/ä/g, "ae") - .replace(/ö/g, "oe") - .replace(/ü/g, "ue") - .replace(/ß/g, "ss") - .replace(/[^a-z0-9]+/g, ""); -} - -const TODAY = new Date(); - -// ── Name pools ─────────────────────────────────────────────── -const MALE_FIRST_NAMES = [ - "Michael", "Andreas", "Thomas", "Stefan", "Christian", "Martin", "Markus", "Daniel", - "Christoph", "Alexander", "Wolfgang", "Peter", "Josef", "Franz", "Johann", "Georg", - "Bernhard", "Florian", "Manuel", "Philipp", "Sebastian", "Patrick", "Dominik", "Simon", - "Lukas", "David", "Matthias", "Robert", "Gerhard", "Helmut", "Karl", "Anton", "Rudolf", - "Herbert", "Kurt", "Werner", "Erwin", "Hannes", "Fabian", "Julian", -]; -const FEMALE_FIRST_NAMES = [ - "Maria", "Anna", "Sabine", "Andrea", "Claudia", "Petra", "Julia", "Sarah", "Lisa", - "Nicole", "Christine", "Elisabeth", "Monika", "Barbara", "Karin", "Silvia", "Martina", - "Susanne", "Katharina", "Eva", "Michaela", "Stephanie", "Verena", "Melanie", "Sandra", - "Birgit", "Ingrid", "Renate", "Gabriele", "Brigitte", "Theresa", "Laura", "Hannah", - "Johanna", "Magdalena", "Carina", "Vanessa", "Nadine", "Bettina", "Ursula", -]; -const LAST_NAMES = [ - "Gruber", "Huber", "Bauer", "Wagner", "Müller", "Pichler", "Steiner", "Moser", "Mayer", - "Hofer", "Leitner", "Berger", "Fuchs", "Eder", "Fischer", "Schmid", "Winkler", "Weber", - "Schwarz", "Maier", "Schneider", "Reiter", "Mayr", "Wolf", "Aigner", "Lang", - "Baumgartner", "Auer", "Brunner", "Wallner", "Wimmer", "Egger", "Binder", "Wieser", - "Höller", "Schmidt", "Riegler", "Kaiser", "Lechner", "Kogler", "Peer", "Lehner", - "Zimmermann", "Pöll", "Haas", "Novak", "Horvat", "Vukovic", "Yilmaz", "Demir", "Kovac", - "Nemec", "Simic", "Kralj", "Toth", "Szabo", -]; - -const NATIONALITIES: readonly (readonly [string, number])[] = [ - ["Österreich", 75], - ["Deutschland", 5], - ["Tschechien", 3], - ["Slowenien", 2], - ["Türkei", 5], - ["Serbien", 3], - ["Kroatien", 3], - ["Bosnien", 2], - ["Ungarn", 2], -]; -function addressCountryFor(nationality: string): string { - return ["Österreich", "Deutschland", "Tschechien", "Slowenien"].includes(nationality) ? nationality : "Andere"; -} - -const STREETS = ["Hauptstraße", "Bahnhofstraße", "Schulgasse", "Kirchenplatz", "Gartenweg", "Industriestraße", "Ringstraße", "Feldweg"]; - -// Home address should be plausible for the employee's actual work location, -// not a one-size-fits-all Vienna postal code regardless of where they're -// based (found during the consolidation review). -const HOME_LOCALE_BY_LOCATION: Record string }> = { - "Wien-Hernals": { city: "Wien", postal: () => String(randInt(1100, 1230)) }, - Wolkersdorf: { city: "Wolkersdorf", postal: () => "2120" }, - Köln: { city: "Köln", postal: () => String(randInt(50667, 51149)) }, - Brünn: { city: "Brno", postal: () => `${randInt(600, 664)} ${randInt(10, 99)}` }, - Ljubljana: { city: "Ljubljana", postal: () => "1000" }, -}; -const EXIT_REASONS = [ - "Einvernehmliche Auflösung", "Kündigung AN", "Kündigung AG", "Befristungsablauf", "Pensionierung", "Entlassung", -]; - -// ── Locations ──────────────────────────────────────────────── -const LOCATIONS = [ - { id: randomUUID(), name: "Wien-Hernals", country: "Österreich" }, - { id: randomUUID(), name: "Wolkersdorf", country: "Österreich" }, - { id: randomUUID(), name: "Köln", country: "Deutschland" }, - { id: randomUUID(), name: "Brünn", country: "Tschechien" }, - { id: randomUUID(), name: "Ljubljana", country: "Slowenien" }, -] as const; -const LOCATION_WEIGHTS: readonly (readonly [(typeof LOCATIONS)[number], number])[] = [ - [LOCATIONS[0], 55], - [LOCATIONS[1], 20], - [LOCATIONS[2], 10], - [LOCATIONS[3], 10], - [LOCATIONS[4], 5], -]; - -// ── Org structure ──────────────────────────────────────────── -// TeamDef/DeptDef/DivisionDef kommen aus build-org.ts — dort steht auch, was -// daraus gebaut wird. - -const SCALE = 1.44; // brings the ~556-person base roster up to ~800 - -const DIVISIONS: DivisionDef[] = [ - { - name: "Produktion", - headTitle: "Bereichsleitung Produktion", - departments: [ - { - name: "Fertigung", - leadTitle: "Abteilungsleitung Fertigung", - teams: [ - { name: "Montage", leadTitle: "Teamleitung Montage", icTitles: ["Maschinenbediener:in", "Montagemitarbeiter:in", "Anlagenführer:in"], baseSize: 45 }, - { name: "CNC-Fertigung", leadTitle: "Teamleitung CNC-Fertigung", icTitles: ["CNC-Fräser:in", "CNC-Dreher:in", "Zerspanungstechniker:in"], baseSize: 35 }, - { name: "Qualitätssicherung Fertigung", leadTitle: "Teamleitung Qualitätssicherung Fertigung", icTitles: ["Qualitätsprüfer:in", "Messtechniker:in"], baseSize: 22 }, - ], - }, - { - name: "Instandhaltung", - leadTitle: "Abteilungsleitung Instandhaltung", - teams: [ - { name: "Elektrotechnik", leadTitle: "Teamleitung Elektrotechnik", icTitles: ["Elektrotechniker:in", "Automatisierungstechniker:in"], baseSize: 24 }, - { name: "Mechanik", leadTitle: "Teamleitung Mechanik", icTitles: ["Industriemechaniker:in", "Schlosser:in"], baseSize: 22 }, - ], - }, - ], - }, - { - name: "Logistik & Einkauf", - headTitle: "Bereichsleitung Logistik & Einkauf", - departments: [ - { - name: "Logistik", - leadTitle: "Abteilungsleitung Logistik", - teams: [ - { name: "Lager", leadTitle: "Teamleitung Lager", icTitles: ["Lagerlogistiker:in", "Staplerfahrer:in", "Kommissionierer:in"], baseSize: 30 }, - { name: "Versand", leadTitle: "Teamleitung Versand", icTitles: ["Versandmitarbeiter:in", "Speditionskaufmann/-frau"], baseSize: 20 }, - { name: "Fuhrpark", leadTitle: "Teamleitung Fuhrpark", icTitles: ["Berufskraftfahrer:in", "Fuhrparkdisponent:in"], baseSize: 16 }, - ], - }, - { - name: "Einkauf", - leadTitle: "Abteilungsleitung Einkauf", - teams: [ - { name: "Strategischer Einkauf", leadTitle: "Teamleitung Strategischer Einkauf", icTitles: ["Einkäufer:in", "Category Manager:in"], baseSize: 14 }, - { name: "Operativer Einkauf", leadTitle: "Teamleitung Operativer Einkauf", icTitles: ["Operative:r Einkäufer:in", "Bestelldisponent:in"], baseSize: 14 }, - ], - }, - ], - }, - { - name: "Vertrieb & Marketing", - headTitle: "Bereichsleitung Vertrieb & Marketing", - departments: [ - { - name: "Vertrieb", - leadTitle: "Abteilungsleitung Vertrieb", - teams: [ - { name: "Key Account Management", leadTitle: "Teamleitung Key Account Management", icTitles: ["Key Account Manager:in", "Sales Manager:in"], baseSize: 16 }, - { name: "Außendienst", leadTitle: "Teamleitung Außendienst", icTitles: ["Außendienstmitarbeiter:in", "Gebietsverkaufsleiter:in"], baseSize: 22 }, - { name: "Vertriebsinnendienst", leadTitle: "Teamleitung Vertriebsinnendienst", icTitles: ["Vertriebsinnendienstmitarbeiter:in", "Auftragssachbearbeiter:in"], baseSize: 18 }, - ], - }, - { - name: "Marketing", - leadTitle: "Abteilungsleitung Marketing", - teams: [ - { name: "Brand Marketing", leadTitle: "Teamleitung Brand Marketing", icTitles: ["Brand Manager:in", "Produktmanager:in"], baseSize: 12 }, - { name: "Digital Marketing", leadTitle: "Teamleitung Digital Marketing", icTitles: ["Digital Marketing Manager:in", "Social-Media-Manager:in"], baseSize: 12 }, - ], - }, - ], - }, - { - name: "Forschung & Entwicklung", - headTitle: "Bereichsleitung Forschung & Entwicklung", - departments: [ - { - name: "Produktentwicklung", - leadTitle: "Abteilungsleitung Produktentwicklung", - teams: [ - { name: "Rezeptur & Sensorik", leadTitle: "Teamleitung Rezeptur & Sensorik", icTitles: ["Lebensmitteltechniker:in", "Sensoriker:in"], baseSize: 16 }, - { name: "Verpackungsentwicklung", leadTitle: "Teamleitung Verpackungsentwicklung", icTitles: ["Verpackungstechniker:in", "Packmittelentwickler:in"], baseSize: 12 }, - ], - }, - { - name: "Verfahrenstechnik", - leadTitle: "Abteilungsleitung Verfahrenstechnik", - teams: [ - { name: "Prozessoptimierung", leadTitle: "Teamleitung Prozessoptimierung", icTitles: ["Verfahrenstechniker:in", "Prozessingenieur:in"], baseSize: 14 }, - { name: "Anlagentechnik", leadTitle: "Teamleitung Anlagentechnik", icTitles: ["Anlagentechniker:in", "Projektingenieur:in"], baseSize: 12 }, - ], - }, - ], - }, - { - name: "Qualitätsmanagement", - headTitle: "Bereichsleitung Qualitätsmanagement", - departments: [ - { - name: "Qualitätssicherung", - leadTitle: "Abteilungsleitung Qualitätssicherung", - teams: [ - { name: "Wareneingangsprüfung", leadTitle: "Teamleitung Wareneingangsprüfung", icTitles: ["Qualitätsprüfer:in", "Wareneingangskontrolleur:in"], baseSize: 14 }, - { name: "Prozessaudit", leadTitle: "Teamleitung Prozessaudit", icTitles: ["Qualitätsauditor:in", "QM-Beauftragte:r"], baseSize: 10 }, - ], - }, - { - name: "Lebensmittelsicherheit", - leadTitle: "Abteilungsleitung Lebensmittelsicherheit", - teams: [ - { name: "Hygienemanagement", leadTitle: "Teamleitung Hygienemanagement", icTitles: ["Hygienebeauftragte:r", "Lebensmittelsicherheitsbeauftragte:r"], baseSize: 12 }, - { name: "Zertifizierung", leadTitle: "Teamleitung Zertifizierung", icTitles: ["Zertifizierungsmanager:in", "QM-Sachbearbeiter:in"], baseSize: 10 }, - ], - }, - ], - }, - { - name: "IT", - headTitle: "Bereichsleitung IT", - departments: [ - { - name: "Business Applications", - leadTitle: "Abteilungsleitung Business Applications", - teams: [ - { name: "SAP-Team", leadTitle: "Teamleitung SAP-Team", icTitles: ["SAP-Consultant", "SAP-Entwickler:in"], baseSize: 12 }, - { name: "Power Platform & Automatisierung", leadTitle: "Teamleitung Power Platform & Automatisierung", icTitles: ["Power Platform Developer:in", "Prozessautomatisierer:in"], baseSize: 10 }, - ], - }, - { - name: "Infrastruktur", - leadTitle: "Abteilungsleitung Infrastruktur", - teams: [ - { name: "Netzwerk & Security", leadTitle: "Teamleitung Netzwerk & Security", icTitles: ["Netzwerktechniker:in", "IT-Security-Spezialist:in"], baseSize: 12 }, - { name: "IT-Support", leadTitle: "Teamleitung IT-Support", icTitles: ["IT-Support-Mitarbeiter:in", "Systemadministrator:in"], baseSize: 14 }, - ], - }, - ], - }, - { - name: "Finanzen & Controlling", - headTitle: "Bereichsleitung Finanzen & Controlling", - departments: [ - { - name: "Finanzen", - leadTitle: "Abteilungsleitung Finanzen", - teams: [ - { name: "Buchhaltung", leadTitle: "Teamleitung Buchhaltung", icTitles: ["Buchhalter:in", "Bilanzbuchhalter:in"], baseSize: 16 }, - { name: "Treasury", leadTitle: "Teamleitung Treasury", icTitles: ["Treasury-Manager:in", "Finanzanalyst:in"], baseSize: 10 }, - ], - }, - { - name: "Controlling", - leadTitle: "Abteilungsleitung Controlling", - teams: [ - { name: "Konzerncontrolling", leadTitle: "Teamleitung Konzerncontrolling", icTitles: ["Controller:in", "Financial Analyst:in"], baseSize: 12 }, - { name: "Werkscontrolling", leadTitle: "Teamleitung Werkscontrolling", icTitles: ["Werkscontroller:in", "Kostenrechner:in"], baseSize: 12 }, - ], - }, - ], - }, - { - name: "Human Resources", - headTitle: "Bereichsleitung Human Resources", - departments: [ - { - name: "HR Business Partner", - leadTitle: "Abteilungsleitung HR Business Partner", - teams: [ - { name: "Recruiting", leadTitle: "Teamleitung Recruiting", icTitles: ["Recruiter:in", "Talent Acquisition Manager:in"], baseSize: 10 }, - { name: "Personalentwicklung", leadTitle: "Teamleitung Personalentwicklung", icTitles: ["Personalentwickler:in", "Trainer:in"], baseSize: 8 }, - ], - }, - { - name: "Personaladministration", - leadTitle: "Abteilungsleitung Personaladministration", - teams: [ - { name: "Gehaltsabrechnung", leadTitle: "Teamleitung Gehaltsabrechnung", icTitles: ["Payroll-Spezialist:in", "Personalverrechner:in"], baseSize: 10 }, - { name: "HR-Systeme", leadTitle: "Teamleitung HR-Systeme", icTitles: ["HR-IT-Spezialist:in", "HRIS Manager:in"], baseSize: 8 }, - ], - }, - ], - }, -]; - -// ── Employee generation ────────────────────────────────────── -type EmployeeRow = { - id: string; - first_name: string; - last_name: string; - gender: "m" | "w"; - birth_date: string; - sv_nummer: string; - nationality: string; - address: string; - postal_code: string; - city: string; - address_country: string; - email: string; - phone: string; - // Die Einordnung in die Organisation steckt jetzt ausschliesslich in der - // Planstelle (position_assignments -> om_positions -> org_units). Keine - // division_id/team_id/manager_id mehr auf der Person. - job_title: string; - location_id: string; - employment_type: "Vollzeit" | "Teilzeit"; - weekly_hours: number; - contract_type: "unbefristet" | "befristet"; - contract_end_date: string | null; - paygrade: "A" | "B" | "C" | "D" | "E" | "F"; - source: "Intern" | "Extern"; - status: "Aktiv" | "Karenz" | "Geplant" | "Ausgetreten"; - entry_date: string; - exit_date: string | null; - exit_reason: string | null; - karenz_start_date: string | null; - karenz_return_date: string | null; - absence_type: string | null; -}; - -type HistoryRow = { - employee_id: string; - event_date: string; - event_type: string; - description: string; -}; - -const usedEmails = new Set(); - -// Eine Domain, die niemandem gehört und offensichtlich keine echte ist. -// `employees.email` ist die *private* Adresse; eine erfundene Testdomain, die -// wie eine Firmenadresse aussieht, lädt dazu ein, sie für eine zu halten — -// und im schlimmsten Fall, an sie zu schreiben. -const MAIL_DOMAIN = "privat.alpenwerk-test.at"; - -function makeEmail(firstName: string, lastName: string): string { - const base = `${slugify(firstName)}.${slugify(lastName)}`; - let email = `${base}@${MAIL_DOMAIN}`; - let n = 2; - while (usedEmails.has(email)) { - email = `${base}${n}@${MAIL_DOMAIN}`; - n += 1; - } - usedEmails.add(email); - return email; -} - -// Used a random four-digit prefix before, so the check digit was right only -// by chance — which the SVNR validation trigger now rejects outright. The -// serial is still random; only the check digit is computed for it. -function makeSvNummer(birthDate: Date): string { - const dd = String(birthDate.getDate()).padStart(2, "0"); - const mm = String(birthDate.getMonth() + 1).padStart(2, "0"); - const yy = String(birthDate.getFullYear()).slice(-2); - const tail = `${dd}${mm}${yy}`; - - // Not every serial yields a usable check digit (a weighted sum of 11 is - // skipped rather than wrapped), so draw until one does. - for (;;) { - const serial = String(randInt(1, 999)).padStart(3, "0"); - const check = svnrCheckDigit(`${serial}0${tail}`); - if (check !== null) return `${serial}${check} ${tail}`; - } -} - -function birthDateForAge(age: number): Date { - const year = TODAY.getFullYear() - age; - return new Date(year, randInt(0, 11), randInt(1, 28)); -} - -function paygradeForIc(): EmployeeRow["paygrade"] { - return weightedPick([ - ["A", 15], - ["B", 35], - ["C", 30], - ["D", 20], - ]); -} - -function newHireBase(jobTitle: string) { - const gender: "m" | "w" = chance(0.48) ? "m" : "w"; - const firstName = pick(gender === "m" ? MALE_FIRST_NAMES : FEMALE_FIRST_NAMES); - const lastName = pick(LAST_NAMES); - const nationality = weightedPick(NATIONALITIES); - const location = weightedPick(LOCATION_WEIGHTS); - const homeLocale = HOME_LOCALE_BY_LOCATION[location.name]; - - return { - id: randomUUID(), - first_name: firstName, - last_name: lastName, - gender, - nationality, - address: `${pick(STREETS)} ${randInt(1, 90)}`, - postal_code: homeLocale.postal(), - city: homeLocale.city, - address_country: addressCountryFor(nationality), - email: makeEmail(firstName, lastName), - phone: `+43 664 ${randInt(1000000, 9999999)}`, - job_title: jobTitle, - location_id: location.id, - }; -} - -const employees: EmployeeRow[] = []; -const history: HistoryRow[] = []; - -function finalizeEmployee( - base: ReturnType, - opts: { paygrade: EmployeeRow["paygrade"]; entryDate?: Date } -): EmployeeRow { - // Alter und Eintritt hängen zusammen: sonst entstehen Beschäftigte, die mit - // sechs Jahren angefangen haben. Ist der Eintritt vorgegeben (ausgetretene - // Vorgänger:innen, geplante Eintritte), richtet sich das Alter danach — - // sonst umgekehrt. - let age: number; - let entryDate: Date; - if (opts.entryDate) { - entryDate = opts.entryDate; - const tenureYears = Math.max(0, Math.floor((TODAY.getTime() - entryDate.getTime()) / (365.25 * 864e5))); - const minAge = Math.min(Math.max(22, 20 + tenureYears), 55); - age = randInt(minAge, 62); - } else { - age = randInt(22, 60); - const maxTenureYears = Math.min(15, age - 20); - entryDate = randomDateBetween(addDays(TODAY, -maxTenureYears * 365), addDays(TODAY, -30)); - } - const birthDate = birthDateForAge(age); - - const employmentType: "Vollzeit" | "Teilzeit" = chance(0.8) ? "Vollzeit" : "Teilzeit"; - const weeklyHours = employmentType === "Vollzeit" ? 38.5 : pick([15, 18, 20, 25, 28, 30, 32, 35]); - - const isBefristet = chance(0.1) && entryDate > addDays(TODAY, -540); - const contractEndDate = isBefristet ? addDays(TODAY, randInt(90, 540)) : null; - - const row: EmployeeRow = { - ...base, - birth_date: isoDate(birthDate), - sv_nummer: makeSvNummer(birthDate), - employment_type: employmentType, - weekly_hours: weeklyHours, - contract_type: isBefristet ? "befristet" : "unbefristet", - contract_end_date: contractEndDate ? isoDate(contractEndDate) : null, - paygrade: opts.paygrade, - source: chance(0.15) ? "Intern" : "Extern", - status: "Aktiv", - entry_date: isoDate(entryDate), - exit_date: null, - exit_reason: null, - karenz_start_date: null, - karenz_return_date: null, - absence_type: null, - }; - - history.push({ - employee_id: row.id, - event_date: row.entry_date, - event_type: "Eintritt", - description: `Eintritt als ${row.job_title}`, - }); - - if (row.status === "Aktiv" && entryDate < addDays(TODAY, -2 * 365) && chance(0.06)) { - const promoDate = randomDateBetween(addDays(entryDate, 365), addDays(TODAY, -30)); - history.push({ - employee_id: row.id, - event_date: isoDate(promoDate), - event_type: "Beförderung", - description: `Beförderung im Rahmen der Laufbahnentwicklung, neue Position: ${row.job_title}`, - }); - } - return row; -} - -// ── Organisation im OM-Modell ──────────────────────────────── -// Der Baum kommt aus buildOrg(): reine Funktion, eigene Tests -// (tests/unit/build-org.test.ts). Der Seed entscheidet hier nur noch, *wer* -// welche Planstelle besetzt — die Struktur selbst ist nicht mehr seine Sache. -const COMPANY_NAME = "Alpenwerk Industrie GmbH"; - -const scaledDivisions: DivisionDef[] = DIVISIONS.map((div) => ({ - ...div, - departments: div.departments.map((dept) => ({ - ...dept, - // -1, weil die Teamleitung im Altmodell Teil der Teamgrösse war und - // buildOrg sie zusätzlich zu icTitles anlegt. - teams: dept.teams.map((t) => ({ ...t, baseSize: Math.max(1, Math.round(t.baseSize * SCALE) - 1) })), - })), -})); - -const org = buildOrg(COMPANY_NAME, scaledDivisions, randomUUID); -const unitById = new Map(org.units.map((u) => [u.id, u])); -const jobTitleById = new Map(org.jobs.map((j) => [j.id, j.title])); - -type AssignmentRow = { - position_id: string; - employee_id: string; - valid_from: string; - valid_to: string | null; -}; -const assignments: AssignmentRow[] = []; - -// Wer welche Planstelle besetzt, wird bewusst nicht überall besetzt: Vakanz -// ist im OM-Modell keine eigene Tabelle mehr, sondern eine Planstelle ohne -// laufende Besetzung. Ein paar davon braucht es, damit "offene Stellen" und -// die Vertretungsregel bei fehlender Leitung überhaupt Daten haben. -const VAKANT_IC = 8; // offene Stellen ohne Nachfolge -const VAKANT_GEPLANT = 3; // offene Stellen mit Eintritt in der Zukunft -const VAKANT_LEITUNG = 3; // unbesetzte Leitungen -> Berichtslinie rollt hoch -const AUSGETRETEN = 40; // Vorgänger:innen auf heute besetzten Planstellen -const LANGZEITABWESEND = 12; -const GEPLANTER_AUSTRITT = 3; - -function shuffle(arr: T[]): T[] { - const a = [...arr]; - for (let i = a.length - 1; i > 0; i--) { - const j = randInt(0, i); - [a[i], a[j]] = [a[j], a[i]]; - } - return a; -} - -function paygradeForPosition(p: (typeof org.positions)[number], title: string): EmployeeRow["paygrade"] { - if (!p.is_chief) return title.startsWith("Assistenz") ? "C" : paygradeForIc(); - const type = unitById.get(p.org_unit_id)!.unit_type; - return type === "Gesellschaft" || type === "Bereich" ? "F" : "E"; -} - -/** Besetzt eine Planstelle laufend und legt die Person an. */ -function occupy(p: (typeof org.positions)[number]): EmployeeRow { - const title = jobTitleById.get(p.job_id)!; - const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForPosition(p, title) }); - employees.push(e); - assignments.push({ position_id: p.id, employee_id: e.id, valid_from: e.entry_date, valid_to: null }); - return e; -} - -const chiefPositions = org.positions.filter((p) => p.is_chief); -const icPositions = org.positions.filter((p) => !p.is_chief); - -// Leitungen: alle besetzen bis auf ein paar Teamleitungen, damit die -// Hochrollen-Regel im Organigramm sichtbar wird. -const vakanteLeitungen = new Set( - shuffle(chiefPositions.filter((p) => unitById.get(p.org_unit_id)!.unit_type === "Team")) - .slice(0, VAKANT_LEITUNG) - .map((p) => p.id) -); -const leadEmployees: EmployeeRow[] = []; -for (const p of chiefPositions) { - if (vakanteLeitungen.has(p.id)) continue; - leadEmployees.push(occupy(p)); -} - -// Mitarbeiter-Planstellen: der Rest wird besetzt, ein Teil bleibt offen. -const shuffledIc = shuffle(icPositions); -const offeneStellen = shuffledIc.slice(0, VAKANT_IC); -const geplanteStellen = shuffledIc.slice(VAKANT_IC, VAKANT_IC + VAKANT_GEPLANT); -const besetzteIc = shuffledIc.slice(VAKANT_IC + VAKANT_GEPLANT); - -const icEmployees = besetzteIc.map((p) => occupy(p)); -void offeneStellen; // bleiben unbesetzt — genau das macht sie zu offenen Stellen - -// ── Statusverteilung (§5) ──────────────────────────────────── -const statusPool = shuffle(icEmployees); -let cursor = 0; - -// Eintritt in der Zukunft: die Person ist angelegt, die Planstelle heute noch -// vakant, die Besetzung beginnt erst. Genau der Fall, für den die Planstellen -// zeitabhängig sind. -for (const p of geplanteStellen) { - const futureEntry = addDays(TODAY, randInt(10, 90)); - const title = jobTitleById.get(p.job_id)!; - const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForIc(), entryDate: futureEntry }); - e.status = "Geplant"; - employees.push(e); - assignments.push({ position_id: p.id, employee_id: e.id, valid_from: e.entry_date, valid_to: null }); -} - -// Langzeitabwesenheit, über die Arten gestreut statt alle als Karenz — sonst -// ist die Auswertung nach Art nicht zu sehen. Zwei davon treffen bewusst eine -// Teamleitung, damit die Vertretungsregel auch mit *abwesender* (nicht nur -// unbesetzter) Leitung Daten hat. -const abwesende: EmployeeRow[] = [ - ...shuffle(leadEmployees.filter((e) => e.job_title.startsWith("Teamleitung"))).slice(0, 2), -]; -while (abwesende.length < LANGZEITABWESEND && cursor < statusPool.length) { - abwesende.push(statusPool[cursor++]); -} -for (const e of abwesende) { - const entryDate = new Date(e.entry_date); - const karenzStart = randomDateBetween(addDays(entryDate, 180), addDays(TODAY, -10)); - const returnDate = addDays(TODAY, randInt(10, 300)); - const absenceType = pick(ABSENCE_TYPES); - e.status = "Karenz"; - e.karenz_start_date = isoDate(karenzStart); - e.karenz_return_date = isoDate(returnDate); - e.absence_type = absenceType; - history.push({ - employee_id: e.id, - event_date: isoDate(karenzStart), - event_type: "Karenz", - description: `${absenceType}, geplante Rückkehr am ${isoDate(returnDate)}`, - }); -} - -// Geplante Austritte: noch aktiv, die Besetzung endet an einem Datum in der -// Zukunft. -for (let i = 0; i < GEPLANTER_AUSTRITT && cursor < statusPool.length; i++, cursor++) { - const e = statusPool[cursor]; - const futureExit = addDays(TODAY, randInt(10, 90)); - e.exit_date = isoDate(futureExit); - e.exit_reason = pick(EXIT_REASONS); - const a = assignments.find((x) => x.employee_id === e.id)!; - a.valid_to = e.exit_date; -} - -// ── Ausgetretene als Vorgänger:innen auf besetzten Planstellen ─────── -// Im Altmodell hingen Ausgetretene weiter an einem Team und liessen dessen -// Planstellen als vakant erscheinen. Im OM-Modell hat eine Planstelle eine -// Besetzungshistorie: die vorherige Besetzung ist beendet, die heutige läuft. -// Voraussetzung ist, dass der Austritt vor dem Eintritt der heutigen -// Besetzung liegt — sonst wäre die Planstelle zweimal gleichzeitig besetzt. -{ - const holderOf = new Map(icEmployees.map((e) => [e.id, e])); - const uebernehmbar = shuffle( - assignments.filter((a) => { - const holder = holderOf.get(a.employee_id); - // Genug Vorlauf, damit vor der heutigen Besetzung noch eine ganze - // Beschäftigung Platz hat. - return holder && new Date(holder.entry_date) > addDays(TODAY, -8 * 365) && holder.status === "Aktiv"; - }) - ).slice(0, AUSGETRETEN); - - for (const a of uebernehmbar) { - const nachfolgerEintritt = new Date(a.valid_from); - const exitDate = addDays(nachfolgerEintritt, -randInt(1, 60)); - const entryDate = addDays(exitDate, -randInt(400, 3000)); - const p = org.positions.find((x) => x.id === a.position_id)!; - const title = jobTitleById.get(p.job_id)!; - - const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForIc(), entryDate }); - e.status = "Ausgetreten"; - e.exit_date = isoDate(exitDate); - e.exit_reason = pick(EXIT_REASONS); - // Ein befristeter Vertrag, der nach dem Austritt endet, wäre Unsinn; und - // finalizeEmployee kann eine Beförderung bis heute gestreut haben, die - // hier nach dem Austritt läge. - e.contract_type = "unbefristet"; - e.contract_end_date = null; - for (let i = history.length - 1; i >= 0; i--) { - if (history[i].employee_id === e.id && history[i].event_date > e.exit_date) history.splice(i, 1); - } - employees.push(e); - history.push({ - employee_id: e.id, - event_date: e.exit_date, - event_type: "Austritt", - description: `Austritt (${e.exit_reason})`, - }); - assignments.push({ - position_id: p.id, - employee_id: e.id, - valid_from: e.entry_date, - valid_to: e.exit_date, - }); - } -} - -// ── Insert helpers ─────────────────────────────────────────── -/** Eltern vor Kindern, damit parent_id beim Einfügen schon existiert. */ -function sortParentsFirst(units: BuiltUnit[]): BuiltUnit[] { - const byParent = new Map(); - for (const u of units) { - const list = byParent.get(u.parent_id) ?? []; - list.push(u); - byParent.set(u.parent_id, list); - } - const out: BuiltUnit[] = []; - const queue = [...(byParent.get(null) ?? [])]; - while (queue.length > 0) { - const u = queue.shift()!; - out.push(u); - queue.push(...(byParent.get(u.id) ?? [])); - } - if (out.length !== units.length) throw new Error("Org-Baum hat abgehängte Einheiten"); - return out; -} - -async function insertInChunks(table: string, rows: Record[], chunkSize = 200) { - for (let i = 0; i < rows.length; i += chunkSize) { - const chunk = rows.slice(i, i + chunkSize); - const { error } = await supabase.from(table).insert(chunk); - if (error) throw new Error(`Insert into ${table} failed: ${error.message}`); - } - console.log(` inserted ${rows.length} row(s) into ${table}`); -} - -// Alles ausser den Anmeldekonten. profiles und auth.users bleiben stehen — -// sonst sperrt sich der Seed selbst aus der Anwendung aus. -// -// Reihenfolge: Kinder vor Eltern. org_units verweist auf sich selbst; ein -// einzelnes DELETE über alle Zeilen geht trotzdem durch, weil Postgres die -// Fremdschlüsselprüfung erst nach dem Statement auswertet. -const WIPE_ORDER = [ - "pending_org_changes", - "hire_drafts", - "employee_notes", - "employee_dependents", - "employee_history", - "position_assignments", - "audit_log", - "saved_reports", - "employees", - "om_positions", - "jobs", - "org_units", - "locations", -]; - -async function wipe() { - for (const table of WIPE_ORDER) { - // PostgREST verlangt einen Filter; "id ist nicht null" trifft alles. - const { error } = await supabase.from(table).delete().not("id", "is", null); - if (error) throw new Error(`Delete from ${table} failed: ${error.message}`); - const { count } = await supabase.from(table).select("*", { count: "exact", head: true }); - if (count) throw new Error(`${table} ist nach dem Löschen nicht leer (${count} Zeilen)`); - console.log(` geleert: ${table}`); - } -} - -/** - * Prüft die Zusagen, die der Seed der Datenbank gegenüber macht, bevor er sie - * löscht. Die Unique-Indizes fangen das Meiste ab — aber erst nach dem - * Löschen, und dann steht die Datenbank leer da. - */ -function pruefeInvarianten() { - const laufend = assignments.filter((a) => a.valid_to === null); - - const jeStelle = new Map(); - for (const a of laufend) jeStelle.set(a.position_id, (jeStelle.get(a.position_id) ?? 0) + 1); - for (const [id, n] of jeStelle) if (n > 1) throw new Error(`Planstelle ${id} ist ${n}-fach laufend besetzt`); - - const jePerson = new Map(); - for (const a of laufend) jePerson.set(a.employee_id, (jePerson.get(a.employee_id) ?? 0) + 1); - for (const [id, n] of jePerson) if (n > 1) throw new Error(`Person ${id} hat ${n} laufende Planstellen`); - - // Überlappende Besetzungen derselben Planstelle: der Unique-Index deckt nur - // die laufende ab, die Historie könnte sich also unbemerkt überschneiden. - const nachStelle = new Map(); - for (const a of assignments) { - const list = nachStelle.get(a.position_id) ?? []; - list.push(a); - nachStelle.set(a.position_id, list); - } - for (const [id, list] of nachStelle) { - const sortiert = [...list].sort((x, y) => x.valid_from.localeCompare(y.valid_from)); - for (let i = 1; i < sortiert.length; i++) { - const vorher = sortiert[i - 1]; - if (vorher.valid_to === null || vorher.valid_to > sortiert[i].valid_from) { - throw new Error(`Planstelle ${id}: Besetzungen überschneiden sich (${vorher.valid_from}–${vorher.valid_to})`); - } - } - } - - for (const a of assignments) { - if (a.valid_to !== null && a.valid_to <= a.valid_from) throw new Error(`Besetzung ${a.position_id}: valid_to <= valid_from`); - } - - const personen = new Set(employees.map((e) => e.id)); - for (const a of assignments) if (!personen.has(a.employee_id)) throw new Error("Besetzung ohne Person"); - for (const h of history) if (!personen.has(h.employee_id)) throw new Error("Historie ohne Person"); - - // Jede Person genau eine Planstelle — auch die ausgetretenen, sonst hinge - // sie ausserhalb der Organisation. - const mitStelle = new Set(assignments.map((a) => a.employee_id)); - for (const e of employees) if (!mitStelle.has(e.id)) throw new Error(`${e.first_name} ${e.last_name} hat keine Planstelle`); - - // Die SV-Nummer trägt das Geburtsdatum in sich; weichen die beiden - // voneinander ab, weist der Trigger die Zeile zurück — mitten im Einfügen, - // wenn die Datenbank bereits leergeräumt ist. - for (const e of employees) { - const fehler = validateSvnr(e.sv_nummer, e.birth_date); - if (fehler) throw new Error(`${e.email}: SV-Nummer ${e.sv_nummer} zu Geburtsdatum ${e.birth_date} — ${svnrErrorMessage(fehler)}`); - } - - for (const e of employees) { - if (e.exit_date && e.exit_date <= e.entry_date) throw new Error(`${e.email}: Austritt vor Eintritt`); - } - for (const h of history) { - const e = employees.find((x) => x.id === h.employee_id)!; - if (e.exit_date && h.event_date > e.exit_date) throw new Error(`${e.email}: Ereignis ${h.event_type} nach dem Austritt`); - // Die Gegenrichtung — und die hat gefehlt. - // - // trg_history_not_before_entry weist jede Zeile ab, deren event_date vor - // dem Eintritt liegt. insertInChunks bricht beim ersten Fehler ab, und - // employee_history ist die *letzte* Tabelle im Seed: alles davor war - // bereits geschrieben. Ergebnis war eine Datenbank mit 852 Personen und - // null Historie — und das sah nicht nach einem Abbruch aus, sondern nach - // einer Anwendung, die eben wenig Historie zeigt. - if (h.event_date < e.entry_date) { - throw new Error(`${e.email}: Ereignis ${h.event_type} am ${h.event_date} liegt vor dem Eintritt am ${e.entry_date}`); - } - } -} - -async function main() { - pruefeInvarianten(); - - if (process.argv.includes("--dry-run")) { - console.log("Trockenlauf — es wird nichts geschrieben."); - berichte(); - return; - } - - console.log("Lösche alle Daten (Anmeldekonten bleiben)..."); - await wipe(); - - console.log("\nSeeding locations..."); - await insertInChunks("locations", LOCATIONS.map((l) => ({ ...l }))); - - console.log(`Seeding ${org.units.length} org_units...`); - // Eltern vor Kindern: der Fremdschlüssel auf parent_id wird pro Zeile - // geprüft, und insertInChunks zerlegt in mehrere Statements. buildOrg - // liefert die Einheiten bereits in dieser Reihenfolge, aber darauf soll - // sich der Seed nicht verlassen. - await insertInChunks("org_units", sortParentsFirst(org.units)); - - console.log(`Seeding ${org.jobs.length} jobs...`); - await insertInChunks("jobs", org.jobs); - - console.log(`Seeding ${org.positions.length} Planstellen...`); - await insertInChunks("om_positions", org.positions); - - console.log(`Seeding ${employees.length} employees...`); - await insertInChunks("employees", employees); - - console.log(`Seeding ${assignments.length} Besetzungen...`); - await insertInChunks("position_assignments", assignments); - - console.log(`Seeding ${history.length} employee_history rows...`); - await insertInChunks("employee_history", history); - - // Das HR-Konto wird nicht neu angelegt: die Auth-Konten überstehen den - // Seed, und ein zweites Konto auf dieselbe Adresse liesse sich gar nicht - // anlegen. Fehlt es, wird es einmalig erzeugt — das ist die eine bewusste - // Freischaltung, jede weitere profiles-Zeile startet mit is_active = false - // und muss von HR freigeschaltet werden (§2.3). - const { data: existing } = await supabase.from("profiles").select("id, email").eq("email", ADMIN_EMAIL).maybeSingle(); - if (existing) { - console.log(`\nHR-Konto ${ADMIN_EMAIL} besteht weiter — Passwort unverändert.`); - } else { - console.log("\nLege HR-Konto an..."); - const hrPassword = randomUUID().slice(0, 12) + "!Aa1"; - const { data: hrUser, error: hrErr } = await supabase.auth.admin.createUser({ - email: ADMIN_EMAIL, - password: hrPassword, - email_confirm: true, - }); - if (hrErr) throw new Error(`Creating HR user failed: ${hrErr.message}`); - await supabase.from("profiles").insert({ - id: hrUser.user.id, - email: ADMIN_EMAIL, - full_name: "Maximilian Stubhan", - role: "hr", - is_active: true, - }); - console.log(`HR login: ${ADMIN_EMAIL} / ${hrPassword}`); - console.log("(Password is shown once here only — store it somewhere safe.)"); - } - - // Gegenprobe an der Datenbank selbst: die Berichtslinie wird nicht mehr - // gepflegt, sondern abgeleitet. Wenn der Seed den Baum falsch verdrahtet - // hat, fällt das hier auf und nicht erst im Organigramm. - const { data: linien, error: linienErr } = await supabase.rpc("om_reporting_lines", { p_as_of: isoDate(TODAY) }); - if (linienErr) throw new Error(`om_reporting_lines failed: ${linienErr.message}`); - const ohneVorgesetzte = (linien ?? []).filter( - (l: { acting_manager_id: string | null }) => l.acting_manager_id === null - ); - console.log(`\nBerichtslinie: ${linien?.length} Zeilen, ${ohneVorgesetzte.length} ohne Vorgesetzte (erwartet: 1, die Geschäftsführung)`); - - console.log("\nFertig."); - berichte(); -} - -function berichte() { - const heute = isoDate(TODAY); - const laufendHeute = assignments.filter((a) => a.valid_from <= heute && (a.valid_to === null || a.valid_to > heute)); - const zahl = (t: string) => org.units.filter((u) => u.unit_type === t).length; - - console.log(` Organisation: ${zahl("Gesellschaft")} Gesellschaft, ${zahl("Bereich")} Bereiche, ${zahl("Abteilung")} Abteilungen, ${zahl("Team")} Teams`); - console.log(` Jobkatalog: ${org.jobs.length} Tätigkeiten`); - console.log(` Planstellen: ${org.positions.length}, davon ${org.positions.length - laufendHeute.length} heute unbesetzt`); - console.log(` Personen: ${employees.length}`); - for (const s of ["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const) { - console.log(` ${s.padEnd(12)} ${employees.filter((e) => e.status === s).length}`); - } - console.log(` Besetzungen: ${assignments.length} (${assignments.filter((a) => a.valid_to !== null).length} beendet)`); - console.log(` Historie: ${history.length} Ereignisse`); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/tests/components/HistorieTab.test.tsx b/tests/components/HistorieTab.test.tsx index 2a23f0e..04dd447 100644 --- a/tests/components/HistorieTab.test.tsx +++ b/tests/components/HistorieTab.test.tsx @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { HistorieTab } from "@/components/employees/tabs/HistorieTab"; import { ToastProvider } from "@/components/ui/Toast"; -import type { Database } from "@/lib/supabase/types"; +import type { Database } from "@/lib/types"; // Der Löschknopf hängt an einer Server-Action, und die zieht über lib/db das // Paket `server-only` nach — im Test ein Fehler beim Import. Ersetzt wird diff --git a/tests/components/TerminatePanel.test.tsx b/tests/components/TerminatePanel.test.tsx index 5d9fa0c..a7668c3 100644 --- a/tests/components/TerminatePanel.test.tsx +++ b/tests/components/TerminatePanel.test.tsx @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { TerminatePanel } from "@/components/employees/panels/TerminatePanel"; import { ToastProvider } from "@/components/ui/Toast"; -import type { Database } from "@/lib/supabase/types"; +import type { Database } from "@/lib/types"; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; diff --git a/tests/components/VertragTab.test.tsx b/tests/components/VertragTab.test.tsx index 259653e..f96894d 100644 --- a/tests/components/VertragTab.test.tsx +++ b/tests/components/VertragTab.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { VertragTab } from "@/components/employees/tabs/VertragTab"; -import type { Database } from "@/lib/supabase/types"; +import type { Database } from "@/lib/types"; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; diff --git a/tests/integration/authorization.test.ts b/tests/integration/authorization.test.ts deleted file mode 100644 index 7dcc038..0000000 --- a/tests/integration/authorization.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { - adminClient, - createBareAuthUser, - createHrUser, - deleteTestUser, - signInAs, - type TestUser, -} from "./helpers"; - -// HR-only access model (supabase/migrations/20260714120000_hr_only_access.sql): -// nobody except an active, explicitly-provisioned HR user may read or write -// anything beyond their own profile row. -describe("HR-only access (is_hr_user gate)", () => { - const createdUsers: TestUser[] = []; - - afterEach(async () => { - while (createdUsers.length) { - const user = createdUsers.pop()!; - await deleteTestUser(user); - } - }); - - it("a bare authenticated user (no profile row) reads zero employees, not an error", async () => { - const user = await createBareAuthUser(); - createdUsers.push(user); - const client = await signInAs(user); - - const { data, error } = await client.from("employees").select("id"); - expect(error).toBeNull(); - expect(data).toEqual([]); - }); - - it("a bare authenticated user cannot insert into org reference tables", async () => { - const user = await createBareAuthUser(); - createdUsers.push(user); - const client = await signInAs(user); - - const { error } = await client - .from("org_units") - .insert({ org_number: "20999999", name: `Test-${user.id}`, unit_type: "Bereich" }); - expect(error).not.toBeNull(); - }); - - it("an inactive HR profile can read its own profile row", async () => { - const user = await createHrUser({ active: false }); - createdUsers.push(user); - const client = await signInAs(user); - - const { data, error } = await client.from("profiles").select("id, is_active").eq("id", user.id); - expect(error).toBeNull(); - expect(data).toHaveLength(1); - expect(data?.[0].is_active).toBe(false); - }); - - it("an inactive HR profile reads zero employees and cannot call mutation RPCs", async () => { - const user = await createHrUser({ active: false }); - createdUsers.push(user); - const client = await signInAs(user); - - const { data: employees, error: readError } = await client.from("employees").select("id"); - expect(readError).toBeNull(); - expect(employees).toEqual([]); - - const { error: rpcError } = await client.rpc("hire_employee", { - payload: { first_name: "X", last_name: "Y", gender: "m", birth_date: "1990-01-01", entry_date: "2020-01-01" }, - }); - expect(rpcError?.message).toMatch(/Nicht berechtigt/); - }); - - it("an active HR user reads the seeded employee roster", async () => { - const user = await createHrUser({ active: true }); - createdUsers.push(user); - const client = await signInAs(user); - - const { data, error } = await client.from("employees").select("id").limit(1); - expect(error).toBeNull(); - expect((data ?? []).length).toBeGreaterThan(0); - }); - - it("apply_due_pending_changes is revoked from authenticated users, even active HR", async () => { - const user = await createHrUser({ active: true }); - createdUsers.push(user); - const client = await signInAs(user); - - const { error } = await client.rpc("apply_due_pending_changes"); - expect(error).not.toBeNull(); - }); - - it("apply_due_pending_changes is callable by the service-role client", async () => { - const { data, error } = await adminClient.rpc("apply_due_pending_changes"); - expect(error).toBeNull(); - expect(typeof data).toBe("number"); - }); -}); diff --git a/tests/integration/data-integrity.test.ts b/tests/integration/data-integrity.test.ts deleted file mode 100644 index e2e44bd..0000000 --- a/tests/integration/data-integrity.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { - adminClient, - createHrUser, - createTestPosition, - deleteTestEmployee, - deleteTestPosition, - deleteTestUser, - hireTestEmployee, - isoDateOffset, - pickSeededUnit, - signInAs, - type TestUser, -} from "./helpers"; -import type { SupabaseClient } from "@supabase/supabase-js"; -import type { Database } from "@/lib/supabase/types"; - -// Two guards added by supabase/migrations/20260714120600_data_integrity_guards.sql -// that previously had no enforcement anywhere: a Karenz return date may not -// precede its own Karenz start, and a history entry may not predate the -// employee's entry date. -describe("data integrity guards", () => { - let hrUser: TestUser; - let hrClient: SupabaseClient; - let unitA: { id: string }; - const employeeIds: string[] = []; - const positionIds: string[] = []; - - beforeAll(async () => { - hrUser = await createHrUser({ active: true }); - hrClient = await signInAs(hrUser); - unitA = await pickSeededUnit(); - }); - - afterAll(async () => { - for (const id of employeeIds) await deleteTestEmployee(id); - for (const id of positionIds) await deleteTestPosition(id); - await deleteTestUser(hrUser); - }); - - // Jede Einstellung braucht im OM-Modell eine freie Zielplanstelle; eine - // geteilte wäre nach der ersten besetzt. - async function freshPosition(): Promise { - const id = await createTestPosition(hrClient, unitA.id, { valid_from: isoDateOffset(-40) }); - positionIds.push(id); - return id; - } - - async function freshEmployeeOnKarenz(): Promise<{ employeeId: string; karenzStartDate: string }> { - const employeeId = await hireTestEmployee(hrClient, await freshPosition()); - employeeIds.push(employeeId); - const karenzStartDate = isoDateOffset(-5); - const { error } = await hrClient.rpc("start_karenz", { - payload: { employee_id: employeeId, karenz_start_date: karenzStartDate, planned_return_date: isoDateOffset(100) }, - }); - if (error) throw new Error(`start_karenz setup failed: ${error.message}`); - return { employeeId, karenzStartDate }; - } - - it("adjust_karenz_return rejects a return date on or before karenz_start_date", async () => { - const { employeeId, karenzStartDate } = await freshEmployeeOnKarenz(); - - const { error } = await hrClient.rpc("adjust_karenz_return", { - payload: { employee_id: employeeId, new_return_date: karenzStartDate }, - }); - expect(error?.message).toMatch(/Rückkehrdatum muss nach dem Karenzbeginn/); - }); - - it("adjust_karenz_return accepts a return date after karenz_start_date", async () => { - const { employeeId } = await freshEmployeeOnKarenz(); - const newReturnDate = isoDateOffset(50); - - const { error } = await hrClient.rpc("adjust_karenz_return", { - payload: { employee_id: employeeId, new_return_date: newReturnDate }, - }); - expect(error).toBeNull(); - - const { data: employee } = await adminClient.from("employees").select("karenz_return_date").eq("id", employeeId).single(); - expect(employee?.karenz_return_date).toBe(newReturnDate); - }); - - it("record_karenz_return rejects a return date on or before karenz_start_date", async () => { - const { employeeId, karenzStartDate } = await freshEmployeeOnKarenz(); - - const { error } = await hrClient.rpc("record_karenz_return", { - payload: { employee_id: employeeId, return_date: karenzStartDate, employment_mode: "unverändert" }, - }); - expect(error?.message).toMatch(/Rückkehrdatum muss nach dem Karenzbeginn/); - }); - - it("record_karenz_return with a valid date flips status back to Aktiv and clears karenz_start_date", async () => { - const { employeeId } = await freshEmployeeOnKarenz(); - - const { error } = await hrClient.rpc("record_karenz_return", { - payload: { employee_id: employeeId, return_date: isoDateOffset(0), employment_mode: "unverändert" }, - }); - expect(error).toBeNull(); - - const { data: employee } = await adminClient - .from("employees") - .select("status, karenz_start_date, karenz_return_date") - .eq("id", employeeId) - .single(); - expect(employee?.status).toBe("Aktiv"); - expect(employee?.karenz_start_date).toBeNull(); - expect(employee?.karenz_return_date).toBeNull(); - }); - - it("rejects an employee_history row dated before the employee's entry_date", async () => { - const employeeId = await hireTestEmployee(hrClient, await freshPosition(), { entry_date: isoDateOffset(-10) }); - employeeIds.push(employeeId); - - const { error } = await adminClient.from("employee_history").insert({ - employee_id: employeeId, - event_date: isoDateOffset(-11), - event_type: "Vertragsänderung", - description: "Sollte vom Trigger abgelehnt werden", - }); - expect(error?.message).toMatch(/darf nicht vor dem Eintrittsdatum/); - }); - - it("accepts an employee_history row dated exactly on the entry_date", async () => { - const entryDate = isoDateOffset(-10); - const employeeId = await hireTestEmployee(hrClient, await freshPosition(), { entry_date: entryDate }); - employeeIds.push(employeeId); - - const { error } = await adminClient.from("employee_history").insert({ - employee_id: employeeId, - event_date: entryDate, - event_type: "Vertragsänderung", - description: "Am Eintrittsdatum selbst, sollte erlaubt sein", - }); - expect(error).toBeNull(); - }); -}); diff --git a/tests/integration/effective-dating.test.ts b/tests/integration/effective-dating.test.ts deleted file mode 100644 index 9263240..0000000 --- a/tests/integration/effective-dating.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { - adminClient, - chiefOfUnit, - createHrUser, - createTestPosition, - deleteTestEmployee, - deleteTestPosition, - deleteTestUser, - hireTestEmployee, - isoDateOffset, - pickSeededUnit, - signInAs, - type TestUser, -} from "./helpers"; -import type { SupabaseClient } from "@supabase/supabase-js"; -import type { Database } from "@/lib/supabase/types"; - -// Deferred/effective-dated changes (supabase/migrations/20260714120200_ -// effective_dating_rpcs.sql): a future "wirksam ab" date must queue a -// pending_org_changes row instead of writing immediately; -// apply_due_pending_changes() applies it once due. -// -// Im OM-Modell ist eine Versetzung der Wechsel auf eine Zielplanstelle, und -// die Berichtslinie wird nicht mehr mitgeschrieben. Geprüft wird deshalb die -// laufende Besetzung und was om_reporting_lines daraus ableitet — nicht mehr -// employees.team_id/manager_id, die es nicht mehr gibt. -describe("effective-dated mutations", () => { - let hrUser: TestUser; - let hrClient: SupabaseClient; - let unitA: { id: string }; - let unitB: { id: string }; - const employeeIds: string[] = []; - const positionIds: string[] = []; - - beforeAll(async () => { - hrUser = await createHrUser({ active: true }); - hrClient = await signInAs(hrUser); - unitA = await pickSeededUnit(); - unitB = await pickSeededUnit(unitA.id); - }); - - afterAll(async () => { - for (const id of employeeIds) await deleteTestEmployee(id); - for (const id of positionIds) await deleteTestPosition(id); - await deleteTestUser(hrUser); - }); - - /** Eine Wegwerf-Planstelle in `unitId`, alt genug für einen Eintritt vor 30 Tagen. */ - async function freshPosition(unitId: string): Promise { - const positionId = await createTestPosition(hrClient, unitId, { valid_from: isoDateOffset(-40) }); - positionIds.push(positionId); - return positionId; - } - - async function freshEmployee(unitId: string): Promise { - const id = await hireTestEmployee(hrClient, await freshPosition(unitId)); - employeeIds.push(id); - return id; - } - - async function lineOf(employeeId: string) { - const { data } = await adminClient - .rpc("om_reporting_lines", { p_as_of: isoDateOffset(0) }) - .eq("employee_id", employeeId) - .single(); - return data as unknown as { org_unit_id: string; formal_manager_id: string | null }; - } - - it("transfer_employee with today's date writes immediately", async () => { - const employeeId = await freshEmployee(unitA.id); - const target = await freshPosition(unitB.id); - - const { error } = await hrClient.rpc("transfer_employee", { - payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: target }, - }); - expect(error).toBeNull(); - - const { data: assignment } = await adminClient - .from("position_assignments") - .select("position_id") - .eq("employee_id", employeeId) - .is("valid_to", null) - .single(); - expect(assignment?.position_id).toBe(target); - - const line = await lineOf(employeeId); - expect(line.org_unit_id).toBe(unitB.id); - expect(line.formal_manager_id).toBe(await chiefOfUnit(unitB.id)); - }); - - it("transfer_employee with a future date defers the write and applies it once due", async () => { - const employeeId = await freshEmployee(unitA.id); - const target = await freshPosition(unitB.id); - - const { error } = await hrClient.rpc("transfer_employee", { - payload: { employee_id: employeeId, effective_date: isoDateOffset(30), target_position_id: target }, - }); - expect(error).toBeNull(); - - // Not written yet — this is the exact bug the migration fixes: a - // future-dated transfer must not overwrite the live record today. - expect((await lineOf(employeeId)).org_unit_id).toBe(unitA.id); - - const { data: pending } = await adminClient - .from("pending_org_changes") - .select("id, status, payload") - .eq("employee_id", employeeId) - .eq("change_type", "transfer") - .single(); - expect(pending?.status).toBe("pending"); - expect(pending?.payload.target_position_id).toBe(target); - - // Fast-forward: simulate the effective date having arrived, then run - // the same function the daily cron route calls. - await adminClient.from("pending_org_changes").update({ effective_date: isoDateOffset(0) }).eq("id", pending!.id); - const { data: appliedCount, error: applyError } = await adminClient.rpc("apply_due_pending_changes"); - expect(applyError).toBeNull(); - expect(appliedCount).toBeGreaterThanOrEqual(1); - - const { data: assignment } = await adminClient - .from("position_assignments") - .select("position_id") - .eq("employee_id", employeeId) - .is("valid_to", null) - .single(); - expect(assignment?.position_id).toBe(target); - expect((await lineOf(employeeId)).org_unit_id).toBe(unitB.id); - - const { data: appliedRow } = await adminClient - .from("pending_org_changes") - .select("status, applied_at") - .eq("id", pending!.id) - .single(); - expect(appliedRow?.status).toBe("applied"); - expect(appliedRow?.applied_at).not.toBeNull(); - }); - - it("promote_employee with a future date does not change job_title/paygrade until applied", async () => { - const employeeId = await freshEmployee(unitA.id); - - const { error } = await hrClient.rpc("promote_employee", { - payload: { employee_id: employeeId, effective_date: isoDateOffset(14), new_title: "Senior Testperson", new_paygrade: "D" }, - }); - expect(error).toBeNull(); - - const { data: unchanged } = await adminClient.from("employees").select("job_title, paygrade").eq("id", employeeId).single(); - expect(unchanged?.job_title).not.toBe("Senior Testperson"); - - const { data: pending } = await adminClient - .from("pending_org_changes") - .select("id") - .eq("employee_id", employeeId) - .eq("change_type", "promotion") - .single(); - await adminClient.from("pending_org_changes").update({ effective_date: isoDateOffset(0) }).eq("id", pending!.id); - await adminClient.rpc("apply_due_pending_changes"); - - const { data: employee } = await adminClient.from("employees").select("job_title, paygrade").eq("id", employeeId).single(); - expect(employee?.job_title).toBe("Senior Testperson"); - expect(employee?.paygrade).toBe("D"); - }); - - it("start_karenz with a future date sets karenz_start_date immediately but keeps status Aktiv", async () => { - const employeeId = await freshEmployee(unitA.id); - const startDate = isoDateOffset(20); - const returnDate = isoDateOffset(200); - - const { error } = await hrClient.rpc("start_karenz", { - payload: { employee_id: employeeId, karenz_start_date: startDate, planned_return_date: returnDate }, - }); - expect(error).toBeNull(); - - const { data: employee } = await adminClient - .from("employees") - .select("status, karenz_start_date") - .eq("id", employeeId) - .single(); - expect(employee?.status).toBe("Aktiv"); - expect(employee?.karenz_start_date).toBe(startDate); - - const { data: pending } = await adminClient - .from("pending_org_changes") - .select("id") - .eq("employee_id", employeeId) - .eq("change_type", "karenz_start") - .single(); - await adminClient.from("pending_org_changes").update({ effective_date: isoDateOffset(0) }).eq("id", pending!.id); - await adminClient.rpc("apply_due_pending_changes"); - - const { data: afterApply } = await adminClient - .from("employees") - .select("status, karenz_return_date") - .eq("id", employeeId) - .single(); - expect(afterApply?.status).toBe("Karenz"); - expect(afterApply?.karenz_return_date).toBe(returnDate); - }); -}); diff --git a/tests/integration/employee-status-filter.test.ts b/tests/integration/employee-status-filter.test.ts index 3675526..5cd893e 100644 --- a/tests/integration/employee-status-filter.test.ts +++ b/tests/integration/employee-status-filter.test.ts @@ -5,7 +5,7 @@ import { derivedStatusFilter } from "@/lib/employee-status-filter"; import type { Schema } from "@/lib/db/schema"; import { todayIso } from "@/lib/format"; import { deriveStatusAsOf } from "@/lib/reports"; -import type { EmploymentStatus } from "@/lib/supabase/types"; +import type { EmploymentStatus } from "@/lib/types"; // lib/employee-status-filter.ts is a SQL restatement of deriveStatusAsOf(): // the employee list pages in the database and cannot derive status in JS, so diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts deleted file mode 100644 index 4c5f0eb..0000000 --- a/tests/integration/helpers.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { createClient, type SupabaseClient } from "@supabase/supabase-js"; -import { randomUUID } from "node:crypto"; -import type { Database, EmploymentStatus } from "@/lib/supabase/types"; - -const URL = process.env.NEXT_PUBLIC_SUPABASE_URL; -const ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; -const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; - -if (!URL || !ANON_KEY || !SERVICE_ROLE_KEY) { - throw new Error( - "Missing Supabase env vars for integration tests. Start local Supabase (`npx supabase start`) and run " + - "`npm run test:integration`, which loads .env.test.local automatically. See README.md." - ); -} - -export const adminClient: SupabaseClient = createClient(URL, SERVICE_ROLE_KEY, { - auth: { autoRefreshToken: false, persistSession: false }, -}); - -export function anonClient(): SupabaseClient { - return createClient(URL!, ANON_KEY!, { auth: { autoRefreshToken: false, persistSession: false } }); -} - -export type TestUser = { id: string; email: string; password: string }; - -// Creates a real auth.users row (via the admin API) with no profiles row — -// this is exactly the "signed up but never provisioned" state §2.3 -// describes: authenticated, but not HR. -export async function createBareAuthUser(): Promise { - const email = `test-${randomUUID()}@example.test`; - const password = `Test-${randomUUID()}!`; - const { data, error } = await adminClient.auth.admin.createUser({ email, password, email_confirm: true }); - if (error || !data.user) throw new Error(`createBareAuthUser failed: ${error?.message}`); - return { id: data.user.id, email, password }; -} - -export async function createHrUser(opts: { active: boolean }): Promise { - const user = await createBareAuthUser(); - const { error } = await adminClient - .from("profiles") - .insert({ id: user.id, email: user.email, full_name: "Integrationstest HR", role: "hr", is_active: opts.active }); - if (error) throw new Error(`createHrUser profile insert failed: ${error.message}`); - return user; -} - -export async function deleteTestUser(user: TestUser): Promise { - await adminClient.from("profiles").delete().eq("id", user.id); - await adminClient.auth.admin.deleteUser(user.id); -} - -export async function signInAs(user: TestUser): Promise> { - const client = anonClient(); - const { error } = await client.auth.signInWithPassword({ email: user.email, password: user.password }); - if (error) throw new Error(`signInAs(${user.email}) failed: ${error.message}`); - return client; -} - -// Aus dem Seed gezogen. Die Einordnung steht nicht mehr auf der Person, sie -// kommt über die laufende Besetzung — deshalb liefert das hier gleich die -// Planstelle und ihre Einheit mit. -export async function pickSeededEmployee(filter: Partial<{ status: EmploymentStatus; isChief: boolean }> = {}): Promise<{ - id: string; - status: string; - position_id: string; - org_unit_id: string; - is_chief: boolean; -}> { - let q = adminClient - .from("position_assignments") - .select("employee_id, om_positions!inner(id, org_unit_id, is_chief), employees!inner(id, status)") - .is("valid_to", null) - .limit(1); - if (filter.status) q = q.eq("employees.status", filter.status); - if (filter.isChief !== undefined) q = q.eq("om_positions.is_chief", filter.isChief); - - const { data, error } = await q.maybeSingle(); - if (error || !data) throw new Error(`pickSeededEmployee failed: ${error?.message ?? "no matching row"}`); - const row = data as unknown as { - employee_id: string; - om_positions: { id: string; org_unit_id: string; is_chief: boolean }; - employees: { status: string }; - }; - return { - id: row.employee_id, - status: row.employees.status, - position_id: row.om_positions.id, - org_unit_id: row.om_positions.org_unit_id, - is_chief: row.om_positions.is_chief, - }; -} - -/** Eine Organisationseinheit vom Typ Team, nach Möglichkeit eine andere als die gegebene. */ -export async function pickSeededUnit(excludeUnitId?: string): Promise<{ id: string }> { - const { data, error } = await adminClient.from("org_units").select("id").eq("unit_type", "Team").limit(2); - if (error || !data?.length) throw new Error(`pickSeededUnit failed: ${error?.message}`); - return data.find((u) => u.id !== excludeUnitId) ?? data[0]; -} - -/** - * Eine heute unbesetzte Planstelle. Einstellung und Versetzung setzen im - * OM-Modell eine freie Zielplanstelle voraus — ohne die gibt es nichts zu - * testen, deshalb legt der Aufrufer sonst selbst eine an. - */ -export async function pickVacantPosition(): Promise<{ id: string; org_unit_id: string } | null> { - const { data: positions } = await adminClient.from("om_positions").select("id, org_unit_id").is("valid_to", null); - const { data: taken } = await adminClient.from("position_assignments").select("position_id").is("valid_to", null); - const besetzt = new Set((taken ?? []).map((a) => a.position_id)); - return (positions ?? []).find((p) => !besetzt.has(p.id)) ?? null; -} - -export async function pickSeededLocation(): Promise<{ id: string }> { - const { data, error } = await adminClient.from("locations").select("id").limit(1).maybeSingle(); - if (error || !data) throw new Error(`pickSeededLocation failed: ${error?.message ?? "no rows"}`); - return data; -} - -/** Wer die Leitungsplanstelle einer Einheit laufend innehat, falls jemand. */ -export async function chiefOfUnit(orgUnitId: string): Promise { - const { data, error } = await adminClient - .from("om_positions") - .select("position_assignments!inner(employee_id, valid_to)") - .eq("org_unit_id", orgUnitId) - .eq("is_chief", true) - .is("valid_to", null) - .is("position_assignments.valid_to", null) - .maybeSingle(); - if (error) throw new Error(`chiefOfUnit(${orgUnitId}) failed: ${error.message}`); - const row = data as unknown as { position_assignments: { employee_id: string }[] } | null; - return row?.position_assignments[0]?.employee_id ?? null; -} - -// YYYY-MM-DD, offset from today — for building "wirksam ab" test payloads -// without hardcoding dates that eventually go stale. -export function isoDateOffset(days: number): string { - const d = new Date(); - d.setDate(d.getDate() + days); - return d.toISOString().slice(0, 10); -} - -// Stellt eine Wegwerf-Person auf `positionId` ein — über die echte -// hire_employee-RPC, nicht per Insert, damit jeder Mutationstest von einem -// Zustand ausgeht, den die Anwendung selbst herstellen kann. Aufräumen mit -// deleteTestEmployee. -export async function hireTestEmployee( - hrClient: SupabaseClient, - positionId: string, - overrides: Partial> = {} -): Promise { - const location = await pickSeededLocation(); - const payload = { - first_name: "Integrationstest", - last_name: `Person-${randomUUID().slice(0, 8)}`, - gender: "w", - birth_date: "1990-01-01", - location_id: location.id, - // Die Tätigkeit kommt aus dem Job der Planstelle; sie wird nicht - // mitgegeben, sonst könnten die beiden auseinanderlaufen. - position_id: positionId, - entry_date: isoDateOffset(-30), - source: "Extern", - ...overrides, - }; - const { data, error } = await hrClient.rpc("hire_employee", { payload }); - if (error || !data) throw new Error(`hireTestEmployee failed: ${error?.message ?? "no id returned"}`); - return data; -} - -// employees has no cascade from audit_log (target_employee_id is a plain -// FK, immutable log by design) — clear those rows first so the employee -// delete itself doesn't fail with a foreign key violation. employee_history -// and pending_org_changes do cascade on employee_id. -export async function deleteTestEmployee(employeeId: string): Promise { - await adminClient.from("audit_log").delete().eq("target_employee_id", employeeId); - await adminClient.from("employees").delete().eq("id", employeeId); -} - -// Legt eine Wegwerf-Planstelle in `orgUnitId` an, über die echte -// create_position-RPC. Die vorgesetzte Person wird nicht mehr angegeben — -// sie ergibt sich aus der Einheit. Aufräumen mit deleteTestPosition, und -// zwar *vor* den Personen, die darauf sassen. -export async function createTestPosition( - hrClient: SupabaseClient, - orgUnitId: string, - overrides: Partial> = {} -): Promise { - const payload = { - org_unit_id: orgUnitId, - job_title: `Integrationstest-Tätigkeit-${randomUUID().slice(0, 8)}`, - is_chief: false, - ...overrides, - }; - const { data, error } = await hrClient.rpc("create_position", { payload }); - if (error || !data) throw new Error(`createTestPosition failed: ${error?.message ?? "no id returned"}`); - return data; -} - -export async function deleteTestPosition(positionId: string): Promise { - // Besetzungen hängen mit on delete cascade daran, der Job bleibt im - // Katalog — er ist geteilt und gehört keiner einzelnen Planstelle. - await adminClient.from("om_positions").delete().eq("id", positionId); -} diff --git a/tests/integration/om-reporting.test.ts b/tests/integration/om-reporting.test.ts index 06f385a..27e0c42 100644 --- a/tests/integration/om-reporting.test.ts +++ b/tests/integration/om-reporting.test.ts @@ -1,37 +1,64 @@ -import { describe, expect, it } from "vitest"; +import { Pool } from "pg"; +import { afterAll, describe, expect, it } from "vitest"; import { todayIso } from "@/lib/format"; import { resolveReportingLines, type OmHolder, type OmUnit } from "@/lib/om-reporting"; -import { adminClient } from "./helpers"; // Die Berichtslinien-Regel existiert zweimal: als om_reporting_lines() in // der Datenbank und als resolveReportingLines() im Anwendungscode. Zwei // Fassungen derselben Regel driften auseinander, und die Abweichung fällt // niemandem auf — im Organigramm stünde einfach eine andere Führungskraft // als im Export. Also über den gesamten Bestand gegeneinanderhalten. -describe("om_reporting_lines stimmt mit resolveReportingLines überein", () => { +// +// Eigene Verbindung statt der Zugriffsschicht: geprüft wird die Regel, nicht +// die Berechtigung. Mit dem Zeilenschutz dazwischen liefe der Vergleich über +// eine gefilterte Teilmenge und bewiese nichts. +const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 2 }); + +describe.skipIf(!process.env.DATABASE_URL)("om_reporting_lines stimmt mit resolveReportingLines überein", () => { const asOf = todayIso(); - async function fromDatabase() { - const { data, error } = await adminClient.rpc("om_reporting_lines", { p_as_of: asOf }); - if (error) throw new Error(error.message); - return data ?? []; + afterAll(async () => { + await pool.end(); + }); + + type DbZeile = { employee_id: string; formal_manager_id: string | null; acting_manager_id: string | null }; + + async function fromDatabase(): Promise { + const { rows } = await pool.query("select * from om_reporting_lines($1)", [asOf]); + return rows; } async function fromTypeScript() { - const [{ data: units }, { data: assignments }] = await Promise.all([ - adminClient.from("org_units").select("id, parent_id"), - adminClient - .from("position_assignments") - .select("employee_id, position_id, valid_from, valid_to, om_positions(org_unit_id, is_chief, valid_from, valid_to)") - .lte("valid_from", asOf) - .or(`valid_to.is.null,valid_to.gt.${asOf}`), - ]); + const { rows: units } = await pool.query<{ id: string; parent_id: string | null }>( + "select id, parent_id from org_units" + ); - const { data: employees } = await adminClient - .from("employees") - .select("id, karenz_start_date, karenz_return_date"); - const absentById = new Map( - (employees ?? []).map((e) => [ + // Dieselbe Gültigkeitsprüfung wie in der Datenbankfunktion: halboffen, + // das Ende ausgeschlossen. Sie steht hier ausgeschrieben und nicht als + // Aufruf einer gemeinsamen Hilfsfunktion — sonst prüfte der Test die + // Regel gegen sich selbst. + const { rows: besetzungen } = await pool.query<{ + employee_id: string; + position_id: string; + org_unit_id: string; + is_chief: boolean; + }>( + `select a.employee_id, a.position_id, p.org_unit_id, p.is_chief + from position_assignments a + join om_positions p on p.id = a.position_id + where a.valid_from <= $1 and (a.valid_to is null or a.valid_to > $1) + and p.valid_from <= $1 and (p.valid_to is null or p.valid_to > $1)`, + [asOf] + ); + + const { rows: personen } = await pool.query<{ + id: string; + karenz_start_date: string | null; + karenz_return_date: string | null; + }>("select id, karenz_start_date, karenz_return_date from employees"); + + const abwesend = new Map( + personen.map((e) => [ e.id, Boolean( e.karenz_start_date && e.karenz_start_date <= asOf && (!e.karenz_return_date || asOf < e.karenz_return_date) @@ -39,22 +66,14 @@ describe("om_reporting_lines stimmt mit resolveReportingLines überein", () => { ]) ); - const omUnits: OmUnit[] = (units ?? []).map((u) => ({ id: u.id, parentId: u.parent_id })); - const holders: OmHolder[] = (assignments ?? []) - .filter((a) => { - const p = a.om_positions as unknown as { valid_from: string; valid_to: string | null } | null; - return p && p.valid_from <= asOf && (p.valid_to === null || p.valid_to > asOf); - }) - .map((a) => { - const p = a.om_positions as unknown as { org_unit_id: string; is_chief: boolean }; - return { - employeeId: a.employee_id, - positionId: a.position_id, - orgUnitId: p.org_unit_id, - isChief: p.is_chief, - absent: absentById.get(a.employee_id) ?? false, - }; - }); + const omUnits: OmUnit[] = units.map((u) => ({ id: u.id, parentId: u.parent_id })); + const holders: OmHolder[] = besetzungen.map((a) => ({ + employeeId: a.employee_id, + positionId: a.position_id, + orgUnitId: a.org_unit_id, + isChief: a.is_chief, + absent: abwesend.get(a.employee_id) ?? false, + })); return resolveReportingLines(omUnits, holders); } @@ -83,8 +102,7 @@ describe("om_reporting_lines stimmt mit resolveReportingLines überein", () => { it("gibt genau einer Person keine Führungskraft — der obersten Leitung", async () => { const db = await fromDatabase(); - const wurzel = db.filter((r) => r.acting_manager_id === null); - expect(wurzel).toHaveLength(1); + expect(db.filter((r) => r.acting_manager_id === null)).toHaveLength(1); }); it("erzeugt keine Berichtslinie auf sich selbst", async () => { diff --git a/tests/integration/position-assignments.test.ts b/tests/integration/position-assignments.test.ts deleted file mode 100644 index c7e4142..0000000 --- a/tests/integration/position-assignments.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import type { Database } from "@/lib/supabase/types"; -import { - adminClient, - createHrUser, - createTestPosition, - deleteTestEmployee, - deleteTestPosition, - deleteTestUser, - hireTestEmployee, - isoDateOffset, - pickSeededUnit, - signInAs, - type TestUser, -} from "./helpers"; - -// Die Besetzungshistorie (A008). Im Altmodell wurde sie von einem Trigger in -// eine eigene Tabelle mitgeschrieben; jetzt *ist* position_assignments die -// Historie — dieselben Zeilen, aus denen auch der heutige Stand kommt. Damit -// gibt es nichts mehr, was auseinanderlaufen könnte, aber die Invarianten -// müssen umso mehr halten: keine Lücke, keine Überschneidung, höchstens eine -// laufende Besetzung. -// -// Die Tests treiben die echten RPCs, nicht Inserts. -describe("position_assignments als Besetzungshistorie", () => { - let hrUser: TestUser; - let hrClient: SupabaseClient; - let unitA: { id: string }; - let unitB: { id: string }; - const employeeIds: string[] = []; - const positionIds: string[] = []; - - beforeAll(async () => { - hrUser = await createHrUser({ active: true }); - hrClient = await signInAs(hrUser); - unitA = await pickSeededUnit(); - unitB = await pickSeededUnit(unitA.id); - }); - - afterAll(async () => { - // Planstellen vor den Personen: die Besetzungen hängen an beiden. - for (const id of positionIds) await deleteTestPosition(id); - for (const id of employeeIds) await deleteTestEmployee(id); - await deleteTestUser(hrUser); - }); - - async function freshPosition(orgUnitId: string): Promise { - const id = await createTestPosition(hrClient, orgUnitId); - positionIds.push(id); - return id; - } - - async function freshEmployee(positionId: string): Promise { - const id = await hireTestEmployee(hrClient, positionId); - employeeIds.push(id); - return id; - } - - async function assignmentsFor(employeeId: string) { - const { data } = await adminClient - .from("position_assignments") - .select("position_id, valid_from, valid_to") - .eq("employee_id", employeeId) - .order("valid_from"); - return data ?? []; - } - - it("öffnet eine Besetzung bei der Einstellung", async () => { - const positionId = await freshPosition(unitA.id); - const employeeId = await freshEmployee(positionId); - - const rows = await assignmentsFor(employeeId); - expect(rows).toHaveLength(1); - expect(rows[0].position_id).toBe(positionId); - expect(rows[0].valid_to).toBeNull(); - }); - - it("übernimmt die Tätigkeit aus dem Job der Planstelle", async () => { - // Sie wird bei der Einstellung nicht mitgegeben — sonst könnten die - // Tätigkeit auf der Person und die der Planstelle auseinanderlaufen. - const positionId = await freshPosition(unitA.id); - const employeeId = await freshEmployee(positionId); - - const { data: position } = await adminClient - .from("om_positions") - .select("jobs!inner(title)") - .eq("id", positionId) - .single(); - const { data: employee } = await adminClient.from("employees").select("job_title").eq("id", employeeId).single(); - - expect(employee?.job_title).toBe((position as unknown as { jobs: { title: string } }).jobs.title); - }); - - it("weist eine Einstellung auf eine bereits besetzte Planstelle zurück", async () => { - // Der Unique-Index fängt das ebenfalls ab; die RPC soll es vorher mit - // einer Meldung tun, die in der Oberfläche etwas erklärt. - const positionId = await freshPosition(unitA.id); - await freshEmployee(positionId); - - await expect(hireTestEmployee(hrClient, positionId)).rejects.toThrow(/bereits besetzt/i); - }); - - it("schliesst die alte Besetzung und öffnet die neue bei einer Versetzung", async () => { - const from = await freshPosition(unitA.id); - const to = await freshPosition(unitB.id); - const employeeId = await freshEmployee(from); - - const { error } = await hrClient.rpc("transfer_employee", { - payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: to }, - }); - expect(error).toBeNull(); - - const rows = await assignmentsFor(employeeId); - expect(rows).toHaveLength(2); - expect(rows[0].position_id).toBe(from); - expect(rows[0].valid_to).toBe(isoDateOffset(0)); - expect(rows[1].position_id).toBe(to); - expect(rows[1].valid_to).toBeNull(); - // Die Intervalle müssen exakt aneinanderstossen, sonst landet eine - // Stichtagsabfrage in einer Lücke. - expect(rows[1].valid_from).toBe(rows[0].valid_to); - }); - - it("hält höchstens eine laufende Besetzung je Person", async () => { - const from = await freshPosition(unitA.id); - const to = await freshPosition(unitB.id); - const employeeId = await freshEmployee(from); - - await hrClient.rpc("transfer_employee", { - payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: to }, - }); - - const rows = await assignmentsFor(employeeId); - expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1); - }); - - it("weist eine Versetzung auf eine besetzte Zielplanstelle zurück", async () => { - const besetzt = await freshPosition(unitA.id); - await freshEmployee(besetzt); - const andere = await freshPosition(unitB.id); - const employeeId = await freshEmployee(andere); - - const { error } = await hrClient.rpc("transfer_employee", { - payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: besetzt }, - }); - expect(error?.message).toMatch(/bereits besetzt/i); - }); - - it("merkt eine Versetzung in der Zukunft vor, statt sie sofort zu schreiben", async () => { - const from = await freshPosition(unitA.id); - const to = await freshPosition(unitB.id); - const employeeId = await freshEmployee(from); - - await hrClient.rpc("transfer_employee", { - payload: { employee_id: employeeId, effective_date: isoDateOffset(30), target_position_id: to }, - }); - - const rows = await assignmentsFor(employeeId); - expect(rows).toHaveLength(1); - expect(rows[0].position_id).toBe(from); - - const { data: pending } = await adminClient - .from("pending_org_changes") - .select("change_type, effective_date, payload, status") - .eq("employee_id", employeeId); - expect(pending).toHaveLength(1); - expect(pending?.[0].status).toBe("pending"); - expect((pending?.[0].payload as { target_position_id: string }).target_position_id).toBe(to); - }); - - it("gibt die Planstelle beim Austritt frei", async () => { - const positionId = await freshPosition(unitA.id); - const employeeId = await freshEmployee(positionId); - - const { error } = await hrClient.rpc("terminate_employee", { - payload: { employee_id: employeeId, exit_date: isoDateOffset(0), exit_reason: "Kündigung AN" }, - }); - expect(error).toBeNull(); - - const rows = await assignmentsFor(employeeId); - expect(rows.filter((r) => r.valid_to === null)).toHaveLength(0); - expect(rows.at(-1)?.valid_to).toBe(isoDateOffset(0)); - }); - - it("hinterlässt keine überschneidenden Besetzungen auf einer Planstelle", async () => { - // Der Unique-Index deckt nur die *laufende* Besetzung ab; die Historie - // könnte sich unbemerkt überschneiden. - const positionId = await freshPosition(unitA.id); - const ersteR = await freshEmployee(positionId); - await hrClient.rpc("terminate_employee", { - payload: { employee_id: ersteR, exit_date: isoDateOffset(0), exit_reason: "Kündigung AN" }, - }); - await freshEmployee(positionId, ); - - const { data } = await adminClient - .from("position_assignments") - .select("valid_from, valid_to") - .eq("position_id", positionId) - .order("valid_from"); - - const rows = data ?? []; - for (let i = 1; i < rows.length; i++) { - const vorher = rows[i - 1]; - expect(vorher.valid_to === null || vorher.valid_to <= rows[i].valid_from, `Besetzung ${i} überschneidet`).toBe(true); - } - }); - - it("ist ohne aktive HR-Sitzung nicht lesbar", async () => { - const outsider = await createHrUser({ active: false }); - const outsiderClient = await signInAs(outsider); - const { data } = await outsiderClient.from("position_assignments").select("id").limit(1); - expect(data ?? []).toHaveLength(0); - await deleteTestUser(outsider); - }); -}); diff --git a/tests/integration/positions.test.ts b/tests/integration/positions.test.ts deleted file mode 100644 index 4cdd49c..0000000 --- a/tests/integration/positions.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { - adminClient, - createHrUser, - createTestPosition, - deleteTestEmployee, - deleteTestPosition, - deleteTestUser, - hireTestEmployee, - isoDateOffset, - pickSeededUnit, - signInAs, - type TestUser, -} from "./helpers"; -import type { SupabaseClient } from "@supabase/supabase-js"; -import type { Database } from "@/lib/supabase/types"; - -// Planstellenpflege im OM-Modell -// (supabase/migrations/20260727130000_om_cleanup_and_positions.sql). -// -// Eine Planstelle gehört zu einer Organisationseinheit, trägt eine Tätigkeit -// aus dem Job-Katalog und ist entweder Leitung oder nicht. Was früher an der -// Ausschreibung hing — vorgesetzte Person, Team, is_lead — ergibt sich jetzt -// aus der Einheit und wird deshalb hier nicht mehr geprüft: es kann gar nicht -// mehr abweichen. -describe("Planstellen anlegen und schliessen", () => { - let hrUser: TestUser; - let hrClient: SupabaseClient; - let unit: { id: string }; - const positionIds: string[] = []; - const employeeIds: string[] = []; - - beforeAll(async () => { - hrUser = await createHrUser({ active: true }); - hrClient = await signInAs(hrUser); - unit = await pickSeededUnit(); - }); - - afterAll(async () => { - // Personen zuerst: die Besetzung hängt mit on delete cascade an der - // Planstelle, die Person selbst nicht. - for (const id of employeeIds) await deleteTestEmployee(id); - for (const id of positionIds) await deleteTestPosition(id); - await deleteTestUser(hrUser); - }); - - it("übernimmt das angegebene Gültig-ab", async () => { - const validFrom = isoDateOffset(10); - const positionId = await createTestPosition(hrClient, unit.id, { valid_from: validFrom }); - positionIds.push(positionId); - - const { data } = await adminClient.from("om_positions").select("valid_from").eq("id", positionId).single(); - expect(data?.valid_from).toBe(validFrom); - }); - - it("setzt Gültig-ab ohne Angabe auf heute", async () => { - const positionId = await createTestPosition(hrClient, unit.id); - positionIds.push(positionId); - - const { data } = await adminClient.from("om_positions").select("valid_from").eq("id", positionId).single(); - expect(data?.valid_from).toBe(isoDateOffset(0)); - }); - - it("hängt die Planstelle an die angegebene Einheit", async () => { - const positionId = await createTestPosition(hrClient, unit.id); - positionIds.push(positionId); - - const { data } = await adminClient.from("om_positions").select("org_unit_id, is_chief").eq("id", positionId).single(); - expect(data?.org_unit_id).toBe(unit.id); - expect(data?.is_chief).toBe(false); - }); - - it("teilt sich denselben Job-Katalogeintrag, statt ihn zu verdoppeln", async () => { - // Sonst stünden „Schlosser:in" und „Schlosser" nebeneinander und jede - // Auswertung nach Tätigkeit wäre wertlos. - const title = `Geteilte Tätigkeit ${Date.now()}`; - const first = await createTestPosition(hrClient, unit.id, { job_title: title }); - const second = await createTestPosition(hrClient, unit.id, { job_title: title }); - positionIds.push(first, second); - - const { data } = await adminClient.from("om_positions").select("job_id").in("id", [first, second]); - expect(new Set((data ?? []).map((p) => p.job_id)).size).toBe(1); - }); - - it("weist eine Planstelle ohne Tätigkeit zurück", async () => { - const { error } = await hrClient.rpc("create_position", { - payload: { org_unit_id: unit.id, job_title: " " }, - }); - expect(error?.message).toMatch(/Tätigkeit/); - }); - - it("lässt keine zweite Leitungsplanstelle für dieselbe Einheit zu", async () => { - // Der Unique-Index erzwingt das ohnehin; die RPC soll es mit einer - // Meldung abfangen, die in der Oberfläche etwas erklärt. - const { data: existing } = await adminClient - .from("om_positions") - .select("org_unit_id") - .eq("is_chief", true) - .is("valid_to", null) - .limit(1) - .single(); - - const { error } = await hrClient.rpc("create_position", { - payload: { org_unit_id: existing!.org_unit_id, job_title: "Zweite Leitung", is_chief: true }, - }); - expect(error?.message).toMatch(/Leitungsplanstelle/); - }); - - it("löscht eine nie besetzte Planstelle vollständig", async () => { - const positionId = await createTestPosition(hrClient, unit.id); - - const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } }); - expect(error).toBeNull(); - - const { data } = await adminClient.from("om_positions").select("id").eq("id", positionId).maybeSingle(); - expect(data).toBeNull(); - }); - - it("weigert sich, eine besetzte Planstelle zu entfernen", async () => { - const positionId = await createTestPosition(hrClient, unit.id); - positionIds.push(positionId); - employeeIds.push(await hireTestEmployee(hrClient, positionId)); - - const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } }); - expect(error?.message).toMatch(/besetzt/); - }); - - it("schliesst eine früher besetzte Planstelle, statt die Historie zu löschen", async () => { - // Sonst verschwände mit der Planstelle die Besetzungshistorie, und in der - // Personalakte klaffte eine Lücke. - const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) }); - positionIds.push(positionId); - const employeeId = await hireTestEmployee(hrClient, positionId); - employeeIds.push(employeeId); - - await hrClient.rpc("terminate_employee", { - payload: { employee_id: employeeId, exit_date: isoDateOffset(-1), exit_reason: "Integrationstest" }, - }); - - const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } }); - expect(error).toBeNull(); - - const { data } = await adminClient.from("om_positions").select("valid_to").eq("id", positionId).maybeSingle(); - expect(data?.valid_to).toBe(isoDateOffset(0)); - - const { data: history } = await adminClient.from("position_assignments").select("id").eq("position_id", positionId); - expect(history?.length).toBeGreaterThan(0); - }); -}); - -describe("Besetzung", () => { - let hrUser: TestUser; - let hrClient: SupabaseClient; - let unit: { id: string }; - const positionIds: string[] = []; - const employeeIds: string[] = []; - - beforeAll(async () => { - hrUser = await createHrUser({ active: true }); - hrClient = await signInAs(hrUser); - unit = await pickSeededUnit(); - }); - - afterAll(async () => { - for (const id of employeeIds) await deleteTestEmployee(id); - for (const id of positionIds) await deleteTestPosition(id); - await deleteTestUser(hrUser); - }); - - it("lässt eine Planstelle nicht zweimal laufend besetzen", async () => { - const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) }); - positionIds.push(positionId); - employeeIds.push(await hireTestEmployee(hrClient, positionId)); - - await expect(hireTestEmployee(hrClient, positionId)).rejects.toThrow(/bereits besetzt/); - }); - - it("übernimmt die Tätigkeit aus dem Job der Planstelle", async () => { - // Der Titel wird bei der Einstellung nicht mitgegeben; sonst könnten - // Planstelle und Person unterschiedliche Tätigkeiten führen. - const title = `Tätigkeit aus dem Katalog ${Date.now()}`; - const positionId = await createTestPosition(hrClient, unit.id, { job_title: title, valid_from: isoDateOffset(-40) }); - positionIds.push(positionId); - const employeeId = await hireTestEmployee(hrClient, positionId); - employeeIds.push(employeeId); - - const { data } = await adminClient.from("employees").select("job_title").eq("id", employeeId).single(); - expect(data?.job_title).toBe(title); - }); - - it("beendet die Besetzung beim Austritt und macht die Planstelle frei", async () => { - const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) }); - positionIds.push(positionId); - const employeeId = await hireTestEmployee(hrClient, positionId); - employeeIds.push(employeeId); - - await hrClient.rpc("terminate_employee", { - payload: { employee_id: employeeId, exit_date: isoDateOffset(-1), exit_reason: "Integrationstest" }, - }); - - const { data } = await adminClient - .from("position_assignments") - .select("valid_to") - .eq("position_id", positionId) - .eq("employee_id", employeeId) - .single(); - expect(data?.valid_to).toBe(isoDateOffset(-1)); - }); -}); diff --git a/tests/integration/svnr-validation.test.ts b/tests/integration/svnr-validation.test.ts deleted file mode 100644 index 1a4189e..0000000 --- a/tests/integration/svnr-validation.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import type { Database } from "@/lib/supabase/types"; -import { - adminClient, - createHrUser, - createTestPosition, - deleteTestEmployee, - deleteTestPosition, - deleteTestUser, - hireTestEmployee, - isoDateOffset, - pickSeededUnit, - signInAs, - type TestUser, -} from "./helpers"; - -// SVNR validation (supabase/migrations/20260725120000_svnr_validation.sql). -// Enforced by a trigger, so these drive it through the real write paths and -// through a direct update — both must be covered by the same rule. -describe("SVNR validation", () => { - let hrUser: TestUser; - let hrClient: SupabaseClient; - let unit: { id: string }; - const employeeIds: string[] = []; - const positionIds: string[] = []; - - // 3·1 + 7·2 + 9·3 = 44; 010180 contributes 18; 62 mod 11 = 7 - const VALID = "1237 010180"; - const BIRTH_DATE = "1980-01-01"; - - async function locationIn(country: string): Promise { - const { data } = await adminClient.from("locations").select("id").eq("country", country).limit(1).maybeSingle(); - if (!data) throw new Error(`no seeded location in ${country}`); - return data.id; - } - - beforeAll(async () => { - hrUser = await createHrUser({ active: true }); - hrClient = await signInAs(hrUser); - unit = await pickSeededUnit(); - }); - - afterAll(async () => { - for (const id of employeeIds) await deleteTestEmployee(id); - for (const id of positionIds) await deleteTestPosition(id); - await deleteTestUser(hrUser); - }); - - async function hireAt(country: string, overrides: Record = {}): Promise { - // Eine eigene Planstelle je Einstellung: eine geteilte wäre nach der - // ersten besetzt. - const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) }); - positionIds.push(positionId); - const id = await hireTestEmployee(hrClient, positionId, { - location_id: await locationIn(country), - birth_date: BIRTH_DATE, - ...overrides, - }); - employeeIds.push(id); - return id; - } - - describe("is_valid_svnr", () => { - async function check(svnr: string, birthDate: string | null = null): Promise { - const { data, error } = await adminClient.rpc("is_valid_svnr", { p_svnr: svnr, p_birth_date: birthDate }); - if (error) throw new Error(error.message); - return data as unknown as boolean; - } - - it("matches the TypeScript implementation on the documented cases", async () => { - expect(await check(VALID)).toBe(true); - expect(await check("1237010180")).toBe(true); - expect(await check("1234 010180")).toBe(false); // wrong check digit - expect(await check("0007 010180")).toBe(false); // 000 serial - expect(await check("0040 010180")).toBe(false); // check digit would be 10 - expect(await check("1237 011380")).toBe(false); // month 13 - expect(await check("123701018")).toBe(false); // nine digits - }); - - it("cross-checks the birth date when one is given", async () => { - expect(await check(VALID, BIRTH_DATE)).toBe(true); - expect(await check(VALID, "1980-01-02")).toBe(false); - }); - }); - - describe("at an Austrian location", () => { - it("accepts a hire carrying a valid number", async () => { - const id = await hireAt("Österreich", { sv_nummer: VALID }); - const { data } = await adminClient.from("employees").select("sv_nummer").eq("id", id).single(); - expect(data?.sv_nummer).toBe(VALID); - }); - - it("rejects a hire carrying an invalid number", async () => { - await expect(hireAt("Österreich", { sv_nummer: "1234 010180" })).rejects.toThrow(/SV-Nummer/i); - }); - - it("rejects a number whose birth date disagrees with the employee record", async () => { - await expect(hireAt("Österreich", { sv_nummer: VALID, birth_date: "1975-06-30" })).rejects.toThrow(/SV-Nummer/i); - }); - - it("allows the field to stay empty", async () => { - const id = await hireAt("Österreich"); - const { data } = await adminClient.from("employees").select("sv_nummer").eq("id", id).single(); - expect(data?.sv_nummer ?? null).toBeNull(); - }); - - it("rejects an update to an invalid number", async () => { - const id = await hireAt("Österreich", { sv_nummer: VALID }); - const { error } = await adminClient.from("employees").update({ sv_nummer: "9999 010180" }).eq("id", id); - expect(error?.message ?? "").toMatch(/SV-Nummer/i); - }); - }); - - describe("outside Austria", () => { - it("leaves the field free-form", async () => { - // The German equivalent has a different format entirely; validating it - // against the Austrian standard would reject correct data. - const id = await hireAt("Deutschland", { sv_nummer: "12 345678 A 901" }); - const { data } = await adminClient.from("employees").select("sv_nummer").eq("id", id).single(); - expect(data?.sv_nummer).toBe("12 345678 A 901"); - }); - }); - - describe("legacy rows", () => { - it("does not block an unrelated edit when the stored number is invalid", async () => { - // Rows predating the migration hold unvalidated values. A transfer or - // a name change must not fail because of a number nobody is touching. - const id = await hireAt("Deutschland", { sv_nummer: "0000 000000" }); - const { error: moveError } = await adminClient - .from("employees") - .update({ location_id: await locationIn("Österreich") }) - .eq("id", id); - expect(moveError).toBeNull(); - - const { error: nameError } = await adminClient.from("employees").update({ phone: "+43 1 9999999" }).eq("id", id); - expect(nameError).toBeNull(); - }); - }); -}); diff --git a/tests/unit/build-org.test.ts b/tests/unit/build-org.test.ts index c8f77d9..243de37 100644 --- a/tests/unit/build-org.test.ts +++ b/tests/unit/build-org.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildOrg, type DivisionDef } from "@/supabase/build-org"; +import { buildOrg, type DivisionDef } from "@/scripts/build-org"; // Die Konstruktion des Org-Baums ist die Stelle, an der sich Elternbezüge // und Leitungsplanstellen falsch verdrahten lassen, ohne dass es auffällt: diff --git a/tests/unit/db-type-parsers.test.ts b/tests/unit/db-type-parsers.test.ts index 9d9f096..8b57094 100644 --- a/tests/unit/db-type-parsers.test.ts +++ b/tests/unit/db-type-parsers.test.ts @@ -3,7 +3,7 @@ import { beforeAll, describe, expect, it, vi } from "vitest"; // Warum es diesen Test gibt. // -// lib/supabase/types.ts sagt für 16 Spalten `string` und für zwei `number`. +// lib/types.ts sagt für 16 Spalten `string` und für zwei `number`. // Der `pg`-Treiber liefert von Haus aus das Gegenteil: aus `date` wird ein // Date-Objekt, aus `numeric` eine Zeichenkette. // diff --git a/tests/unit/history.test.ts b/tests/unit/history.test.ts index 5670a18..5b51af9 100644 --- a/tests/unit/history.test.ts +++ b/tests/unit/history.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { darfKorrigiertWerden, loeschVorschau } from "@/lib/history"; -import type { AuditChange, HistoryEventType } from "@/lib/supabase/types"; +import type { AuditChange, HistoryEventType } from "@/lib/types"; const HEUTE = "2026-08-13"; diff --git a/tsconfig.json b/tsconfig.json index bb7143d..ced38a0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,9 +9,6 @@ "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", - // supabase/seed.ts is executed directly by Node, which resolves relative - // imports as ESM and therefore needs the explicit .ts extension. - "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts index 03e4a08..56a42db 100644 --- a/vitest.integration.config.ts +++ b/vitest.integration.config.ts @@ -1,11 +1,15 @@ import { defineConfig } from "vitest/config"; import path from "node:path"; -// Runs against a local Supabase instance (`npx supabase start`, then -// `node --env-file=.env.test.local supabase/seed.ts` once) — see -// docs/security.md and README.md "Tests" section for the full setup. These -// tests exercise real RLS policies and RPC functions; they intentionally do -// not run against the hosted project. +// Läuft gegen eine erreichbare Postgres-Datenbank, angegeben über +// DATABASE_URL. Jede Datei hier überspringt sich selbst, wenn die Variable +// fehlt — so bleibt `npm run test:integration` auch ohne Datenbank +// aufrufbar, statt mit einem Verbindungsfehler abzubrechen. +// +// Geprüft werden Regeln, die es zweimal gibt: einmal als SQL in der +// Datenbank, einmal als TypeScript in der Anwendung. Zwei Fassungen +// derselben Regel driften auseinander, und nur eine echte Datenbank kann +// das aufdecken. export default defineConfig({ test: { environment: "node",