From 5e6ee2c456695971cedc3a857ae08c540f808870 Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 11 Sep 2026 15:07:47 +0200 Subject: [PATCH] feat: build TalkTime MVP backend/frontend, rename from VereinsChat - Generic org/team/member data model (renamed from club-specific naming) - Auth, organizations, invitations with mail + Matrix provisioning - Teams with automatic Matrix room creation, sync-job retry, reconciliation - Departments, seasons, roles (RBAC with privilege-escalation guard), audit log - Dashboard, members list, org switcher on the frontend - Synapse setup docs (Docker + bare-metal) and generalized PRD/architecture docs Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TBf3yfnnHzMQ5qhLV2285A --- .gitignore | 5 + DEVLOG.md | 57 + backend/DEVLOG.md | 112 ++ backend/drizzle/0000_nebulous_prism.sql | 173 ++ backend/drizzle/meta/0000_snapshot.json | 1389 ++++++++++++++++ backend/drizzle/meta/_journal.json | 13 + backend/package.json | 2 +- backend/src/db/schema.ts | 282 +++- backend/src/lib/audit.ts | 20 + backend/src/lib/authz.ts | 31 + backend/src/routes/audit.ts | 35 + backend/src/routes/dashboard.ts | 39 + backend/src/routes/departments.ts | 54 + backend/src/routes/invitations.ts | 245 +++ backend/src/routes/members.ts | 101 ++ backend/src/routes/organizations.ts | 79 + backend/src/routes/roles.ts | 161 ++ backend/src/routes/seasons.ts | 59 + backend/src/routes/synapse.ts | 9 + backend/src/routes/teams.ts | 192 +++ backend/src/server.ts | 29 + backend/src/services/mail.ts | 131 ++ backend/src/services/synapse.ts | 138 ++ backend/src/services/sync-worker.ts | 274 ++++ claude.md | 1433 +++++++++++++++++ docs/PRD.md | 49 +- docs/vereinschat/PRD.md | 109 ++ docs/vereinschat/architecture.md | 145 ++ frontend/DEVLOG.md | 167 ++ frontend/index.html | 2 +- frontend/package-lock.json | 111 +- frontend/package.json | 2 +- frontend/src/App.tsx | 32 +- frontend/src/components/auth/LoginForm.tsx | 97 ++ frontend/src/components/auth/RegisterForm.tsx | 99 ++ .../dashboard/CreateOrganizationForm.tsx | 104 ++ .../components/dashboard/DashboardTiles.tsx | 76 + .../components/dashboard/InviteMemberForm.tsx | 100 ++ .../dashboard/OrganizationSwitcher.tsx | 39 + frontend/src/hooks/use-api-list.ts | 56 + frontend/src/hooks/use-dashboard-summary.ts | 59 + frontend/src/hooks/use-members.ts | 74 + frontend/src/lib/auth-context.tsx | 79 + frontend/src/lib/organization-context.tsx | 94 ++ frontend/src/pages/AcceptInvitationPage.tsx | 163 ++ frontend/src/pages/DashboardPage.tsx | 35 + frontend/src/pages/DepartmentsPage.tsx | 85 + frontend/src/pages/LoginPage.tsx | 9 + frontend/src/pages/MembersPage.tsx | 122 ++ frontend/src/pages/RegisterPage.tsx | 9 + frontend/src/pages/TeamsPage.tsx | 156 ++ frontend/src/routes/ProtectedRoute.tsx | 10 + synapse/README.md | 197 +++ synapse/docker-compose.yml | 24 + synapse/homeserver.yaml.sample | 50 + 55 files changed, 7379 insertions(+), 38 deletions(-) create mode 100644 DEVLOG.md create mode 100644 backend/DEVLOG.md create mode 100644 backend/drizzle/0000_nebulous_prism.sql create mode 100644 backend/drizzle/meta/0000_snapshot.json create mode 100644 backend/drizzle/meta/_journal.json create mode 100644 backend/src/lib/audit.ts create mode 100644 backend/src/lib/authz.ts create mode 100644 backend/src/routes/audit.ts create mode 100644 backend/src/routes/dashboard.ts create mode 100644 backend/src/routes/departments.ts create mode 100644 backend/src/routes/invitations.ts create mode 100644 backend/src/routes/members.ts create mode 100644 backend/src/routes/organizations.ts create mode 100644 backend/src/routes/roles.ts create mode 100644 backend/src/routes/seasons.ts create mode 100644 backend/src/routes/synapse.ts create mode 100644 backend/src/routes/teams.ts create mode 100644 backend/src/services/mail.ts create mode 100644 backend/src/services/synapse.ts create mode 100644 backend/src/services/sync-worker.ts create mode 100644 claude.md create mode 100644 docs/vereinschat/PRD.md create mode 100644 docs/vereinschat/architecture.md create mode 100644 frontend/DEVLOG.md create mode 100644 frontend/src/components/auth/LoginForm.tsx create mode 100644 frontend/src/components/auth/RegisterForm.tsx create mode 100644 frontend/src/components/dashboard/CreateOrganizationForm.tsx create mode 100644 frontend/src/components/dashboard/DashboardTiles.tsx create mode 100644 frontend/src/components/dashboard/InviteMemberForm.tsx create mode 100644 frontend/src/components/dashboard/OrganizationSwitcher.tsx create mode 100644 frontend/src/hooks/use-api-list.ts create mode 100644 frontend/src/hooks/use-dashboard-summary.ts create mode 100644 frontend/src/hooks/use-members.ts create mode 100644 frontend/src/lib/auth-context.tsx create mode 100644 frontend/src/lib/organization-context.tsx create mode 100644 frontend/src/pages/AcceptInvitationPage.tsx create mode 100644 frontend/src/pages/DashboardPage.tsx create mode 100644 frontend/src/pages/DepartmentsPage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/MembersPage.tsx create mode 100644 frontend/src/pages/RegisterPage.tsx create mode 100644 frontend/src/pages/TeamsPage.tsx create mode 100644 frontend/src/routes/ProtectedRoute.tsx create mode 100644 synapse/README.md create mode 100644 synapse/docker-compose.yml create mode 100644 synapse/homeserver.yaml.sample diff --git a/.gitignore b/.gitignore index bc847b0..1734ab4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,11 @@ backend/dist/ .env.local !*.env.example +# synapse runtime data / secrets (generated, never committed) +synapse/data/ +synapse/pgdata/ +!synapse/homeserver.yaml.sample + # misc .DS_Store *.pem diff --git a/DEVLOG.md b/DEVLOG.md new file mode 100644 index 0000000..310fe85 --- /dev/null +++ b/DEVLOG.md @@ -0,0 +1,57 @@ +# vsb-talk – Dev Log + +## 2026-09-11 13:35 – 13:38 (3m) +**Beschreibung:** Claude Code Session +**Projekt:** vsb-talk + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +Keine Änderungen ermittelbar. + +--- +## 2026-09-11 13:39 – 13:42 (2m) +**Beschreibung:** Claude Code Session +**Projekt:** vsb-talk + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 13:48 – 13:49 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 14:59 – 15:05 (6m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 15:05 – 15:05 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** vsb-talk + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- diff --git a/backend/DEVLOG.md b/backend/DEVLOG.md new file mode 100644 index 0000000..37f14aa --- /dev/null +++ b/backend/DEVLOG.md @@ -0,0 +1,112 @@ +# backend – Dev Log + +## 2026-09-11 13:50 – 13:51 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** vsb-talk + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 13:58 – 13:59 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 14:31 – 14:33 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 14:36 – 14:36 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** backend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 14:36 – 14:38 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** backend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 14:38 – 14:38 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** backend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 14:43 – 14:44 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 14:45 – 14:46 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** backend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 14:47 – 14:49 (2m) +**Beschreibung:** Claude Code Session +**Projekt:** backend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 14:50 – 14:50 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** backend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- diff --git a/backend/drizzle/0000_nebulous_prism.sql b/backend/drizzle/0000_nebulous_prism.sql new file mode 100644 index 0000000..a82ab20 --- /dev/null +++ b/backend/drizzle/0000_nebulous_prism.sql @@ -0,0 +1,173 @@ +CREATE TYPE "public"."chat_room_scope" AS ENUM('organization', 'department', 'team');--> statement-breakpoint +CREATE TYPE "public"."invitation_status" AS ENUM('pending', 'accepted', 'revoked', 'expired');--> statement-breakpoint +CREATE TYPE "public"."member_status" AS ENUM('active', 'suspended', 'left');--> statement-breakpoint +CREATE TYPE "public"."org_role" AS ENUM('ORG_ADMIN', 'ORG_MANAGER', 'DEPARTMENT_ADMIN', 'TEAM_MANAGER', 'TEAM_LEAD', 'ASSISTANT_LEAD', 'SUPERVISOR', 'MEMBER', 'GUARDIAN');--> statement-breakpoint +CREATE TYPE "public"."sync_job_status" AS ENUM('pending', 'processing', 'done', 'failed');--> statement-breakpoint +CREATE TABLE "audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "actor_user_id" uuid, + "action" text NOT NULL, + "target_type" text NOT NULL, + "target_id" text NOT NULL, + "metadata" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "chat_rooms" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "matrix_room_id" text NOT NULL, + "name" text NOT NULL, + "scope" "chat_room_scope" NOT NULL, + "department_id" uuid, + "team_id" uuid, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "chat_rooms_matrix_room_id_unique" UNIQUE("matrix_room_id") +); +--> statement-breakpoint +CREATE TABLE "departments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "name" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "invitations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "email" text NOT NULL, + "token" text NOT NULL, + "role" "org_role" DEFAULT 'MEMBER' NOT NULL, + "team_id" uuid, + "status" "invitation_status" DEFAULT 'pending' NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "invitations_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "members" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "user_id" uuid, + "first_name" text NOT NULL, + "last_name" text NOT NULL, + "email" text NOT NULL, + "phone" text, + "birth_date" date, + "status" "member_status" DEFAULT 'active' NOT NULL, + "matrix_user_id" text, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "members_organization_id_email_unique" UNIQUE("organization_id","email") +); +--> statement-breakpoint +CREATE TABLE "org_roles" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "member_id" uuid NOT NULL, + "department_id" uuid, + "role" "org_role" NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "org_roles_scope_unique" UNIQUE("organization_id","member_id","department_id","role") +); +--> statement-breakpoint +CREATE TABLE "organizations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "slug" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "organizations_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE "parent_links" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "child_member_id" uuid NOT NULL, + "parent_member_id" uuid NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "parent_links_child_parent_unique" UNIQUE("child_member_id","parent_member_id") +); +--> statement-breakpoint +CREATE TABLE "seasons" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "name" text NOT NULL, + "start_date" date NOT NULL, + "end_date" date NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sync_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "type" text NOT NULL, + "payload" text NOT NULL, + "status" "sync_job_status" DEFAULT 'pending' NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "last_error" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "team_members" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "team_id" uuid NOT NULL, + "member_id" uuid NOT NULL, + "role" "org_role" DEFAULT 'MEMBER' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "team_members_team_id_member_id_role_unique" UNIQUE("team_id","member_id","role") +); +--> statement-breakpoint +CREATE TABLE "teams" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "department_id" uuid NOT NULL, + "season_id" uuid NOT NULL, + "name" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" text NOT NULL, + "password_hash" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "users_email_unique" UNIQUE("email") +); +--> statement-breakpoint +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actor_user_id_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chat_rooms" ADD CONSTRAINT "chat_rooms_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chat_rooms" ADD CONSTRAINT "chat_rooms_department_id_departments_id_fk" FOREIGN KEY ("department_id") REFERENCES "public"."departments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chat_rooms" ADD CONSTRAINT "chat_rooms_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "departments" ADD CONSTRAINT "departments_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "members" ADD CONSTRAINT "members_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "members" ADD CONSTRAINT "members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "org_roles" ADD CONSTRAINT "org_roles_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "org_roles" ADD CONSTRAINT "org_roles_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "org_roles" ADD CONSTRAINT "org_roles_department_id_departments_id_fk" FOREIGN KEY ("department_id") REFERENCES "public"."departments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "parent_links" ADD CONSTRAINT "parent_links_child_member_id_members_id_fk" FOREIGN KEY ("child_member_id") REFERENCES "public"."members"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "parent_links" ADD CONSTRAINT "parent_links_parent_member_id_members_id_fk" FOREIGN KEY ("parent_member_id") REFERENCES "public"."members"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "seasons" ADD CONSTRAINT "seasons_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sync_jobs" ADD CONSTRAINT "sync_jobs_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "teams" ADD CONSTRAINT "teams_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "teams" ADD CONSTRAINT "teams_department_id_departments_id_fk" FOREIGN KEY ("department_id") REFERENCES "public"."departments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "teams" ADD CONSTRAINT "teams_season_id_seasons_id_fk" FOREIGN KEY ("season_id") REFERENCES "public"."seasons"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "audit_logs_organization_id_idx" ON "audit_logs" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "chat_rooms_organization_id_idx" ON "chat_rooms" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "departments_organization_id_idx" ON "departments" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "invitations_organization_id_idx" ON "invitations" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "members_organization_id_idx" ON "members" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "org_roles_organization_id_idx" ON "org_roles" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "org_roles_member_id_idx" ON "org_roles" USING btree ("member_id");--> statement-breakpoint +CREATE INDEX "seasons_organization_id_idx" ON "seasons" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "sync_jobs_organization_id_idx" ON "sync_jobs" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "sync_jobs_status_idx" ON "sync_jobs" USING btree ("status");--> statement-breakpoint +CREATE INDEX "team_members_team_id_idx" ON "team_members" USING btree ("team_id");--> statement-breakpoint +CREATE INDEX "team_members_member_id_idx" ON "team_members" USING btree ("member_id");--> statement-breakpoint +CREATE INDEX "teams_organization_id_idx" ON "teams" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "teams_department_id_idx" ON "teams" USING btree ("department_id"); \ No newline at end of file diff --git a/backend/drizzle/meta/0000_snapshot.json b/backend/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..1564753 --- /dev/null +++ b/backend/drizzle/meta/0000_snapshot.json @@ -0,0 +1,1389 @@ +{ + "id": "deed0d60-b691-42ed-b003-21e4b363a37a", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_organization_id_idx": { + "name": "audit_logs_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_organization_id_organizations_id_fk": { + "name": "audit_logs_organization_id_organizations_id_fk", + "tableFrom": "audit_logs", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_user_id_users_id_fk": { + "name": "audit_logs_actor_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_rooms": { + "name": "chat_rooms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "matrix_room_id": { + "name": "matrix_room_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "chat_room_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "department_id": { + "name": "department_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_rooms_organization_id_idx": { + "name": "chat_rooms_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_rooms_organization_id_organizations_id_fk": { + "name": "chat_rooms_organization_id_organizations_id_fk", + "tableFrom": "chat_rooms", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_rooms_department_id_departments_id_fk": { + "name": "chat_rooms_department_id_departments_id_fk", + "tableFrom": "chat_rooms", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_rooms_team_id_teams_id_fk": { + "name": "chat_rooms_team_id_teams_id_fk", + "tableFrom": "chat_rooms", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_rooms_matrix_room_id_unique": { + "name": "chat_rooms_matrix_room_id_unique", + "nullsNotDistinct": false, + "columns": [ + "matrix_room_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.departments": { + "name": "departments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "departments_organization_id_idx": { + "name": "departments_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "departments_organization_id_organizations_id_fk": { + "name": "departments_organization_id_organizations_id_fk", + "tableFrom": "departments", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_team_id_teams_id_fk": { + "name": "invitations_team_id_teams_id_fk", + "tableFrom": "invitations", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitations_token_unique": { + "name": "invitations_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "birth_date": { + "name": "birth_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "matrix_user_id": { + "name": "matrix_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "members_organization_id_email_unique": { + "name": "members_organization_id_email_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_roles": { + "name": "org_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "department_id": { + "name": "department_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_roles_organization_id_idx": { + "name": "org_roles_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_roles_member_id_idx": { + "name": "org_roles_member_id_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_roles_organization_id_organizations_id_fk": { + "name": "org_roles_organization_id_organizations_id_fk", + "tableFrom": "org_roles", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_roles_member_id_members_id_fk": { + "name": "org_roles_member_id_members_id_fk", + "tableFrom": "org_roles", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_roles_department_id_departments_id_fk": { + "name": "org_roles_department_id_departments_id_fk", + "tableFrom": "org_roles", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_roles_scope_unique": { + "name": "org_roles_scope_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "member_id", + "department_id", + "role" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parent_links": { + "name": "parent_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_member_id": { + "name": "child_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_member_id": { + "name": "parent_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "parent_links_child_member_id_members_id_fk": { + "name": "parent_links_child_member_id_members_id_fk", + "tableFrom": "parent_links", + "tableTo": "members", + "columnsFrom": [ + "child_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "parent_links_parent_member_id_members_id_fk": { + "name": "parent_links_parent_member_id_members_id_fk", + "tableFrom": "parent_links", + "tableTo": "members", + "columnsFrom": [ + "parent_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "parent_links_child_parent_unique": { + "name": "parent_links_child_parent_unique", + "nullsNotDistinct": false, + "columns": [ + "child_member_id", + "parent_member_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seasons": { + "name": "seasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "seasons_organization_id_idx": { + "name": "seasons_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "seasons_organization_id_organizations_id_fk": { + "name": "seasons_organization_id_organizations_id_fk", + "tableFrom": "seasons", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_jobs": { + "name": "sync_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sync_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_jobs_organization_id_idx": { + "name": "sync_jobs_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_jobs_status_idx": { + "name": "sync_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_jobs_organization_id_organizations_id_fk": { + "name": "sync_jobs_organization_id_organizations_id_fk", + "tableFrom": "sync_jobs", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_id_idx": { + "name": "team_members_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_member_id_idx": { + "name": "team_members_member_id_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_member_id_members_id_fk": { + "name": "team_members_member_id_members_id_fk", + "tableFrom": "team_members", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_members_team_id_member_id_role_unique": { + "name": "team_members_team_id_member_id_role_unique", + "nullsNotDistinct": false, + "columns": [ + "team_id", + "member_id", + "role" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "department_id": { + "name": "department_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_organization_id_idx": { + "name": "teams_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_department_id_idx": { + "name": "teams_department_id_idx", + "columns": [ + { + "expression": "department_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_organization_id_organizations_id_fk": { + "name": "teams_organization_id_organizations_id_fk", + "tableFrom": "teams", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "teams_department_id_departments_id_fk": { + "name": "teams_department_id_departments_id_fk", + "tableFrom": "teams", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "teams_season_id_seasons_id_fk": { + "name": "teams_season_id_seasons_id_fk", + "tableFrom": "teams", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.chat_room_scope": { + "name": "chat_room_scope", + "schema": "public", + "values": [ + "organization", + "department", + "team" + ] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "revoked", + "expired" + ] + }, + "public.member_status": { + "name": "member_status", + "schema": "public", + "values": [ + "active", + "suspended", + "left" + ] + }, + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "ORG_ADMIN", + "ORG_MANAGER", + "DEPARTMENT_ADMIN", + "TEAM_MANAGER", + "TEAM_LEAD", + "ASSISTANT_LEAD", + "SUPERVISOR", + "MEMBER", + "GUARDIAN" + ] + }, + "public.sync_job_status": { + "name": "sync_job_status", + "schema": "public", + "values": [ + "pending", + "processing", + "done", + "failed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json new file mode 100644 index 0000000..b0b8b7a --- /dev/null +++ b/backend/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1789131255612, + "tag": "0000_nebulous_prism", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 952a00b..f770446 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,5 +1,5 @@ { - "name": "ai-coding-starter-kit-backend", + "name": "talktime-backend", "version": "1.0.0", "private": true, "type": "module", diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index cf29d8a..8531a27 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -1,4 +1,14 @@ -import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core"; +import { + pgTable, + uuid, + text, + timestamp, + date, + integer, + pgEnum, + unique, + index, +} from "drizzle-orm/pg-core"; export const users = pgTable("users", { id: uuid("id").primaryKey().defaultRandom(), @@ -6,3 +16,273 @@ export const users = pgTable("users", { passwordHash: text("password_hash").notNull(), createdAt: timestamp("created_at").notNull().defaultNow(), }); + +export const orgRoleEnum = pgEnum("org_role", [ + "ORG_ADMIN", + "ORG_MANAGER", + "DEPARTMENT_ADMIN", + "TEAM_MANAGER", + "TEAM_LEAD", + "ASSISTANT_LEAD", + "SUPERVISOR", + "MEMBER", + "GUARDIAN", +]); + +export const memberStatusEnum = pgEnum("member_status", [ + "active", + "suspended", + "left", +]); + +export const invitationStatusEnum = pgEnum("invitation_status", [ + "pending", + "accepted", + "revoked", + "expired", +]); + +export const chatRoomScopeEnum = pgEnum("chat_room_scope", [ + "organization", + "department", + "team", +]); + +export const organizations = pgTable("organizations", { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull(), + slug: text("slug").notNull().unique(), + createdAt: timestamp("created_at").notNull().defaultNow(), +}); + +export const departments = pgTable( + "departments", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + name: text("name").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (table) => [index("departments_organization_id_idx").on(table.organizationId)], +); + +export const seasons = pgTable( + "seasons", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + name: text("name").notNull(), + startDate: date("start_date").notNull(), + endDate: date("end_date").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (table) => [index("seasons_organization_id_idx").on(table.organizationId)], +); + +export const teams = pgTable( + "teams", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + departmentId: uuid("department_id") + .notNull() + .references(() => departments.id, { onDelete: "cascade" }), + seasonId: uuid("season_id") + .notNull() + .references(() => seasons.id, { onDelete: "restrict" }), + name: text("name").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (table) => [ + index("teams_organization_id_idx").on(table.organizationId), + index("teams_department_id_idx").on(table.departmentId), + ], +); + +export const members = pgTable( + "members", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }), + firstName: text("first_name").notNull(), + lastName: text("last_name").notNull(), + email: text("email").notNull(), + phone: text("phone"), + birthDate: date("birth_date"), + status: memberStatusEnum("status").notNull().default("active"), + matrixUserId: text("matrix_user_id"), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (table) => [ + index("members_organization_id_idx").on(table.organizationId), + unique("members_organization_id_email_unique").on(table.organizationId, table.email), + ], +); + +export const parentLinks = pgTable( + "parent_links", + { + id: uuid("id").primaryKey().defaultRandom(), + childMemberId: uuid("child_member_id") + .notNull() + .references(() => members.id, { onDelete: "cascade" }), + parentMemberId: uuid("parent_member_id") + .notNull() + .references(() => members.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (table) => [ + unique("parent_links_child_parent_unique").on( + table.childMemberId, + table.parentMemberId, + ), + ], +); + +export const teamMembers = pgTable( + "team_members", + { + id: uuid("id").primaryKey().defaultRandom(), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + memberId: uuid("member_id") + .notNull() + .references(() => members.id, { onDelete: "cascade" }), + role: orgRoleEnum("role").notNull().default("MEMBER"), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (table) => [ + index("team_members_team_id_idx").on(table.teamId), + index("team_members_member_id_idx").on(table.memberId), + unique("team_members_team_id_member_id_role_unique").on( + table.teamId, + table.memberId, + table.role, + ), + ], +); + +export const organizationRoles = pgTable( + "org_roles", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + memberId: uuid("member_id") + .notNull() + .references(() => members.id, { onDelete: "cascade" }), + departmentId: uuid("department_id").references(() => departments.id, { + onDelete: "cascade", + }), + role: orgRoleEnum("role").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (table) => [ + index("org_roles_organization_id_idx").on(table.organizationId), + index("org_roles_member_id_idx").on(table.memberId), + unique("org_roles_scope_unique").on( + table.organizationId, + table.memberId, + table.departmentId, + table.role, + ), + ], +); + +export const invitations = pgTable( + "invitations", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + email: text("email").notNull(), + token: text("token").notNull().unique(), + role: orgRoleEnum("role").notNull().default("MEMBER"), + teamId: uuid("team_id").references(() => teams.id, { onDelete: "cascade" }), + status: invitationStatusEnum("status").notNull().default("pending"), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (table) => [index("invitations_organization_id_idx").on(table.organizationId)], +); + +export const chatRooms = pgTable( + "chat_rooms", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + matrixRoomId: text("matrix_room_id").notNull().unique(), + name: text("name").notNull(), + scope: chatRoomScopeEnum("scope").notNull(), + departmentId: uuid("department_id").references(() => departments.id, { + onDelete: "cascade", + }), + teamId: uuid("team_id").references(() => teams.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (table) => [index("chat_rooms_organization_id_idx").on(table.organizationId)], +); + +export const syncJobStatusEnum = pgEnum("sync_job_status", [ + "pending", + "processing", + "done", + "failed", +]); + +export const syncJobs = pgTable( + "sync_jobs", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + type: text("type").notNull(), + payload: text("payload").notNull(), + status: syncJobStatusEnum("status").notNull().default("pending"), + attempts: integer("attempts").notNull().default(0), + lastError: text("last_error"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (table) => [ + index("sync_jobs_organization_id_idx").on(table.organizationId), + index("sync_jobs_status_idx").on(table.status), + ], +); + +export const auditLogs = pgTable( + "audit_logs", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + actorUserId: uuid("actor_user_id").references(() => users.id, { + onDelete: "set null", + }), + action: text("action").notNull(), + targetType: text("target_type").notNull(), + targetId: text("target_id").notNull(), + metadata: text("metadata"), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (table) => [ + index("audit_logs_organization_id_idx").on(table.organizationId), + index("audit_logs_created_at_idx").on(table.createdAt), + ], +); diff --git a/backend/src/lib/audit.ts b/backend/src/lib/audit.ts new file mode 100644 index 0000000..e1c5e62 --- /dev/null +++ b/backend/src/lib/audit.ts @@ -0,0 +1,20 @@ +import { db } from "../db/index.js"; +import { auditLogs } from "../db/schema.js"; + +export async function logAudit(params: { + organizationId: string; + actorUserId: string | null; + action: string; + targetType: string; + targetId: string; + metadata?: Record; +}): Promise { + await db.insert(auditLogs).values({ + organizationId: params.organizationId, + actorUserId: params.actorUserId, + action: params.action, + targetType: params.targetType, + targetId: params.targetId, + metadata: params.metadata ? JSON.stringify(params.metadata) : null, + }); +} diff --git a/backend/src/lib/authz.ts b/backend/src/lib/authz.ts new file mode 100644 index 0000000..1f98178 --- /dev/null +++ b/backend/src/lib/authz.ts @@ -0,0 +1,31 @@ +import { and, eq } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { members, organizationRoles } from "../db/schema.js"; + +const ADMIN_ROLES = ["ORG_ADMIN", "ORG_MANAGER"] as const; + +export async function findMembership(userId: string, organizationId: string) { + const [membership] = await db + .select({ id: members.id }) + .from(members) + .where(and(eq(members.userId, userId), eq(members.organizationId, organizationId))) + .limit(1); + return membership ?? null; +} + +export async function requesterIsOrganizationMember(userId: string, organizationId: string): Promise { + return (await findMembership(userId, organizationId)) !== null; +} + +export async function requesterIsOrganizationAdmin(userId: string, organizationId: string): Promise { + const membership = await findMembership(userId, organizationId); + if (!membership) return false; + + const [role] = await db + .select({ role: organizationRoles.role }) + .from(organizationRoles) + .where(and(eq(organizationRoles.memberId, membership.id), eq(organizationRoles.organizationId, organizationId))) + .limit(1); + + return role !== undefined && (ADMIN_ROLES as readonly string[]).includes(role.role); +} diff --git a/backend/src/routes/audit.ts b/backend/src/routes/audit.ts new file mode 100644 index 0000000..c377d44 --- /dev/null +++ b/backend/src/routes/audit.ts @@ -0,0 +1,35 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { eq, desc } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { auditLogs } from "../db/schema.js"; +import { requesterIsOrganizationAdmin } from "../lib/authz.js"; + +const listQuerySchema = z.object({ + organizationId: z.string().uuid(), + limit: z.coerce.number().int().min(1).max(100).default(50), +}); + +export async function auditRoutes(app: FastifyInstance) { + app.get("/api/audit-logs", { preHandler: app.authenticate }, async (request, reply) => { + const parsed = listQuerySchema.safeParse(request.query); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { organizationId, limit } = parsed.data; + + const isAdmin = await requesterIsOrganizationAdmin(request.user.sub, organizationId); + if (!isAdmin) { + return reply.code(403).send({ error: "Forbidden" }); + } + + const rows = await db + .select() + .from(auditLogs) + .where(eq(auditLogs.organizationId, organizationId)) + .orderBy(desc(auditLogs.createdAt)) + .limit(limit); + + return { logs: rows }; + }); +} diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts new file mode 100644 index 0000000..8b198d9 --- /dev/null +++ b/backend/src/routes/dashboard.ts @@ -0,0 +1,39 @@ +import type { FastifyInstance } from "fastify"; +import { count, eq } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { members, departments, teams, chatRooms, invitations } from "../db/schema.js"; + +export async function dashboardRoutes(app: FastifyInstance) { + app.get( + "/api/dashboard/summary", + { preHandler: app.authenticate }, + async (request, reply) => { + const organizationId = (request.query as { organizationId?: string }).organizationId; + if (!organizationId) { + return reply.code(400).send({ error: "organizationId query param is required" }); + } + + const [ + [memberRow], + [departmentRow], + [teamRow], + [chatRoomRow], + [invitationRow], + ] = await Promise.all([ + db.select({ value: count() }).from(members).where(eq(members.organizationId, organizationId)), + db.select({ value: count() }).from(departments).where(eq(departments.organizationId, organizationId)), + db.select({ value: count() }).from(teams).where(eq(teams.organizationId, organizationId)), + db.select({ value: count() }).from(chatRooms).where(eq(chatRooms.organizationId, organizationId)), + db.select({ value: count() }).from(invitations).where(eq(invitations.organizationId, organizationId)), + ]); + + return { + members: memberRow?.value ?? 0, + departments: departmentRow?.value ?? 0, + teams: teamRow?.value ?? 0, + chatRooms: chatRoomRow?.value ?? 0, + invitations: invitationRow?.value ?? 0, + }; + }, + ); +} diff --git a/backend/src/routes/departments.ts b/backend/src/routes/departments.ts new file mode 100644 index 0000000..4c0230e --- /dev/null +++ b/backend/src/routes/departments.ts @@ -0,0 +1,54 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { eq } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { departments } from "../db/schema.js"; +import { requesterIsOrganizationMember, requesterIsOrganizationAdmin } from "../lib/authz.js"; + +const listQuerySchema = z.object({ + organizationId: z.string().uuid(), +}); + +const createDepartmentSchema = z.object({ + organizationId: z.string().uuid(), + name: z.string().min(1), +}); + +export async function departmentRoutes(app: FastifyInstance) { + app.get("/api/departments", { preHandler: app.authenticate }, async (request, reply) => { + const parsed = listQuerySchema.safeParse(request.query); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { organizationId } = parsed.data; + + const isMember = await requesterIsOrganizationMember(request.user.sub, organizationId); + if (!isMember) { + return reply.code(403).send({ error: "Forbidden" }); + } + + const rows = await db + .select({ id: departments.id, name: departments.name, createdAt: departments.createdAt }) + .from(departments) + .where(eq(departments.organizationId, organizationId)) + .orderBy(departments.name); + + return { departments: rows }; + }); + + app.post("/api/departments", { preHandler: app.authenticate }, async (request, reply) => { + const parsed = createDepartmentSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { organizationId, name } = parsed.data; + + const isAdmin = await requesterIsOrganizationAdmin(request.user.sub, organizationId); + if (!isAdmin) { + return reply.code(403).send({ error: "Forbidden" }); + } + + const [department] = await db.insert(departments).values({ organizationId, name }).returning(); + return reply.code(201).send(department); + }); +} diff --git a/backend/src/routes/invitations.ts b/backend/src/routes/invitations.ts new file mode 100644 index 0000000..592246c --- /dev/null +++ b/backend/src/routes/invitations.ts @@ -0,0 +1,245 @@ +import { randomBytes } from "node:crypto"; +import type { FastifyInstance } from "fastify"; +import bcrypt from "bcryptjs"; +import { z } from "zod"; +import { and, eq } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { + organizations, + organizationRoles, + invitations, + members, + users, + teamMembers, + orgRoleEnum, +} from "../db/schema.js"; +import { sendMail } from "../services/mail.js"; +import { createUser, toMatrixUserId, toLocalpart, SynapseError } from "../services/synapse.js"; +import { enqueueSyncJob } from "../services/sync-worker.js"; +import { requesterIsOrganizationAdmin } from "../lib/authz.js"; +import { logAudit } from "../lib/audit.js"; + +const appBaseUrl = process.env.APP_BASE_URL ?? "http://localhost:5173"; + +const INVITATION_TTL_DAYS = 7; + +const createInvitationSchema = z.object({ + organizationId: z.string().uuid(), + email: z.string().email(), + role: z.enum(orgRoleEnum.enumValues).default("MEMBER"), + teamId: z.string().uuid().optional(), +}); + +const acceptInvitationSchema = z.object({ + firstName: z.string().min(1), + lastName: z.string().min(1), + password: z.string().min(8).optional(), +}); + +async function provisionMatrixUser( + organizationId: string, + firstName: string, + lastName: string, + memberId: string, +): Promise { + const base = toLocalpart(firstName, lastName); + const password = randomBytes(24).toString("hex"); + const displayName = `${firstName} ${lastName}`; + + for (let suffix = 0; suffix < 10; suffix++) { + const localpart = suffix === 0 ? base : `${base}${suffix + 1}`; + try { + await createUser(localpart, { password, displayName }); + const matrixUserId = toMatrixUserId(localpart); + await db.update(members).set({ matrixUserId }).where(eq(members.id, memberId)); + return; + } catch (err) { + if (err instanceof SynapseError && err.status === 400) { + // Localpart taken — try the next suffix. + continue; + } + console.warn( + `[synapse] inline provisioning failed for member ${memberId}, queuing retry:`, + err, + ); + await enqueueSyncJob(organizationId, "provision_matrix_user", { memberId, firstName, lastName }); + return; + } + } + console.warn(`[synapse] exhausted localpart suffixes for member ${memberId}, queuing retry`); + await enqueueSyncJob(organizationId, "provision_matrix_user", { memberId, firstName, lastName }); +} + +export async function invitationRoutes(app: FastifyInstance) { + app.post( + "/api/invitations", + { preHandler: app.authenticate, config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (request, reply) => { + const parsed = createInvitationSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { organizationId, email, role, teamId } = parsed.data; + + const isAdmin = await requesterIsOrganizationAdmin(request.user.sub, organizationId); + if (!isAdmin) { + return reply.code(403).send({ error: "Forbidden" }); + } + + const token = randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000); + + const [invitation] = await db + .insert(invitations) + .values({ organizationId, email, role, teamId, token, expiresAt }) + .returning({ id: invitations.id, token: invitations.token, expiresAt: invitations.expiresAt }); + + const [organization] = await db + .select({ name: organizations.name }) + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1); + const inviteLink = `${appBaseUrl}/invite/${invitation.token}`; + + await sendMail({ + to: email, + subject: `Einladung zu ${organization?.name ?? "TalkTime"}`, + text: `Du wurdest zu ${organization?.name ?? "einem Verein"} auf TalkTime eingeladen.\n\nEinladung annehmen: ${inviteLink}\n\nLink gültig bis ${expiresAt.toLocaleDateString("de-DE")}.`, + html: `

Du wurdest zu ${organization?.name ?? "einem Verein"} auf TalkTime eingeladen.

Einladung annehmen

Link gültig bis ${expiresAt.toLocaleDateString("de-DE")}.

`, + }); + + await logAudit({ + organizationId, + actorUserId: request.user.sub, + action: "invitation.created", + targetType: "invitation", + targetId: invitation.id, + metadata: { email, role }, + }); + + return reply.code(201).send(invitation); + }, + ); + + app.get("/api/invitations/:token", async (request, reply) => { + const { token } = request.params as { token: string }; + + const [invitation] = await db + .select({ + email: invitations.email, + status: invitations.status, + expiresAt: invitations.expiresAt, + organizationName: organizations.name, + }) + .from(invitations) + .innerJoin(organizations, eq(invitations.organizationId, organizations.id)) + .where(eq(invitations.token, token)) + .limit(1); + + if (!invitation) { + return reply.code(404).send({ error: "Invitation not found" }); + } + if (invitation.status !== "pending" || invitation.expiresAt < new Date()) { + return reply.code(410).send({ error: "Invitation is no longer valid" }); + } + + const [existingUser] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, invitation.email)) + .limit(1); + + return { + email: invitation.email, + organizationName: invitation.organizationName, + requiresPassword: !existingUser, + }; + }); + + app.post( + "/api/invitations/:token/accept", + { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (request, reply) => { + const { token } = request.params as { token: string }; + const parsed = acceptInvitationSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { firstName, lastName, password } = parsed.data; + + const [invitation] = await db + .select() + .from(invitations) + .where(eq(invitations.token, token)) + .limit(1); + + if (!invitation) { + return reply.code(404).send({ error: "Invitation not found" }); + } + if (invitation.status !== "pending" || invitation.expiresAt < new Date()) { + return reply.code(410).send({ error: "Invitation is no longer valid" }); + } + + const [existingMember] = await db + .select({ id: members.id }) + .from(members) + .where(and(eq(members.organizationId, invitation.organizationId), eq(members.email, invitation.email))) + .limit(1); + if (existingMember) { + return reply.code(409).send({ error: "Already a member of this organization" }); + } + + let [user] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, invitation.email)) + .limit(1); + + if (!user) { + if (!password) { + return reply.code(400).send({ error: "Password is required for new accounts" }); + } + const passwordHash = await bcrypt.hash(password, 12); + [user] = await db + .insert(users) + .values({ email: invitation.email, passwordHash }) + .returning({ id: users.id }); + } + + const [member] = await db + .insert(members) + .values({ + organizationId: invitation.organizationId, + userId: user.id, + firstName, + lastName, + email: invitation.email, + }) + .returning({ id: members.id }); + + await provisionMatrixUser(invitation.organizationId, firstName, lastName, member.id); + + await db.insert(organizationRoles).values({ + organizationId: invitation.organizationId, + memberId: member.id, + role: invitation.role, + }); + + if (invitation.teamId) { + await db.insert(teamMembers).values({ + teamId: invitation.teamId, + memberId: member.id, + role: invitation.role, + }); + } + + await db + .update(invitations) + .set({ status: "accepted" }) + .where(eq(invitations.id, invitation.id)); + + const authToken = app.jwt.sign({ sub: user.id, email: invitation.email }); + return reply.code(201).send({ token: authToken }); + }, + ); +} diff --git a/backend/src/routes/members.ts b/backend/src/routes/members.ts new file mode 100644 index 0000000..50dc546 --- /dev/null +++ b/backend/src/routes/members.ts @@ -0,0 +1,101 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { eq, count } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { members, memberStatusEnum } from "../db/schema.js"; +import { requesterIsOrganizationMember, requesterIsOrganizationAdmin } from "../lib/authz.js"; +import { deactivateUser } from "../services/synapse.js"; +import { logAudit } from "../lib/audit.js"; + +const listQuerySchema = z.object({ + organizationId: z.string().uuid(), + limit: z.coerce.number().int().min(1).max(100).default(20), + offset: z.coerce.number().int().min(0).default(0), +}); + +const updateStatusSchema = z.object({ + status: z.enum(memberStatusEnum.enumValues), +}); + +export async function memberRoutes(app: FastifyInstance) { + app.get("/api/members", { preHandler: app.authenticate }, async (request, reply) => { + const parsed = listQuerySchema.safeParse(request.query); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { organizationId, limit, offset } = parsed.data; + + const isMember = await requesterIsOrganizationMember(request.user.sub, organizationId); + if (!isMember) { + return reply.code(403).send({ error: "Forbidden" }); + } + + const [rows, [{ value: total }]] = await Promise.all([ + db + .select({ + id: members.id, + firstName: members.firstName, + lastName: members.lastName, + email: members.email, + status: members.status, + createdAt: members.createdAt, + }) + .from(members) + .where(eq(members.organizationId, organizationId)) + .limit(limit) + .offset(offset) + .orderBy(members.lastName, members.firstName), + db.select({ value: count() }).from(members).where(eq(members.organizationId, organizationId)), + ]); + + return { members: rows, total, limit, offset }; + }); + + app.patch( + "/api/members/:memberId/status", + { preHandler: app.authenticate }, + async (request, reply) => { + const { memberId } = request.params as { memberId: string }; + const parsed = updateStatusSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { status } = parsed.data; + + const [member] = await db + .select({ organizationId: members.organizationId, matrixUserId: members.matrixUserId }) + .from(members) + .where(eq(members.id, memberId)) + .limit(1); + if (!member) { + return reply.code(404).send({ error: "Member not found" }); + } + + const isAdmin = await requesterIsOrganizationAdmin(request.user.sub, member.organizationId); + if (!isAdmin) { + return reply.code(403).send({ error: "Forbidden" }); + } + + await db.update(members).set({ status }).where(eq(members.id, memberId)); + + await logAudit({ + organizationId: member.organizationId, + actorUserId: request.user.sub, + action: "member.status_changed", + targetType: "member", + targetId: memberId, + metadata: { status }, + }); + + if ((status === "suspended" || status === "left") && member.matrixUserId) { + try { + await deactivateUser(member.matrixUserId, false); + } catch (err) { + console.warn(`[synapse] failed to deactivate matrix user for member ${memberId}:`, err); + } + } + + return reply.send({ id: memberId, status }); + }, + ); +} diff --git a/backend/src/routes/organizations.ts b/backend/src/routes/organizations.ts new file mode 100644 index 0000000..678f7cc --- /dev/null +++ b/backend/src/routes/organizations.ts @@ -0,0 +1,79 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { eq } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { organizations, members, organizationRoles, users } from "../db/schema.js"; +import { logAudit } from "../lib/audit.js"; + +const createOrganizationSchema = z.object({ + name: z.string().min(2), + slug: z + .string() + .min(2) + .regex(/^[a-z0-9-]+$/, "Nur Kleinbuchstaben, Ziffern und Bindestriche"), +}); + +export async function organizationRoutes(app: FastifyInstance) { + app.get("/api/organizations/mine", { preHandler: app.authenticate }, async (request) => { + const userId = request.user.sub; + + const rows = await db + .select({ id: organizations.id, name: organizations.name, slug: organizations.slug }) + .from(members) + .innerJoin(organizations, eq(members.organizationId, organizations.id)) + .where(eq(members.userId, userId)); + + return { organizations: rows }; + }); + + app.post("/api/organizations", { preHandler: app.authenticate }, async (request, reply) => { + const parsed = createOrganizationSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { name, slug } = parsed.data; + + const [existingSlug] = await db.select().from(organizations).where(eq(organizations.slug, slug)).limit(1); + if (existingSlug) { + return reply.code(409).send({ error: "Slug already taken" }); + } + + const [requester] = await db + .select({ email: users.email }) + .from(users) + .where(eq(users.id, request.user.sub)) + .limit(1); + if (!requester) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + const [organization] = await db.insert(organizations).values({ name, slug }).returning(); + + const [member] = await db + .insert(members) + .values({ + organizationId: organization.id, + userId: request.user.sub, + firstName: requester.email.split("@")[0], + lastName: "", + email: requester.email, + }) + .returning({ id: members.id }); + + await db.insert(organizationRoles).values({ + organizationId: organization.id, + memberId: member.id, + role: "ORG_ADMIN", + }); + + await logAudit({ + organizationId: organization.id, + actorUserId: request.user.sub, + action: "organization.created", + targetType: "organization", + targetId: organization.id, + }); + + return reply.code(201).send({ id: organization.id, name: organization.name, slug: organization.slug }); + }); +} diff --git a/backend/src/routes/roles.ts b/backend/src/routes/roles.ts new file mode 100644 index 0000000..f2b2515 --- /dev/null +++ b/backend/src/routes/roles.ts @@ -0,0 +1,161 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { and, eq } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { organizationRoles, orgRoleEnum, members } from "../db/schema.js"; +import { findMembership, requesterIsOrganizationAdmin } from "../lib/authz.js"; +import { logAudit } from "../lib/audit.js"; + +const listQuerySchema = z.object({ + organizationId: z.string().uuid(), + memberId: z.string().uuid().optional(), +}); + +const assignRoleSchema = z.object({ + organizationId: z.string().uuid(), + memberId: z.string().uuid(), + role: z.enum(orgRoleEnum.enumValues), + departmentId: z.string().uuid().optional(), +}); + +async function requesterHasRole(userId: string, organizationId: string, role: string): Promise { + const membership = await findMembership(userId, organizationId); + if (!membership) return false; + const [row] = await db + .select({ role: organizationRoles.role }) + .from(organizationRoles) + .where( + and( + eq(organizationRoles.memberId, membership.id), + eq(organizationRoles.organizationId, organizationId), + eq(organizationRoles.role, role as (typeof orgRoleEnum.enumValues)[number]), + ), + ) + .limit(1); + return !!row; +} + +export async function roleRoutes(app: FastifyInstance) { + app.get("/api/organization-roles", { preHandler: app.authenticate }, async (request, reply) => { + const parsed = listQuerySchema.safeParse(request.query); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { organizationId, memberId } = parsed.data; + + const isAdmin = await requesterIsOrganizationAdmin(request.user.sub, organizationId); + if (!isAdmin) { + return reply.code(403).send({ error: "Forbidden" }); + } + + const conditions = memberId + ? and(eq(organizationRoles.organizationId, organizationId), eq(organizationRoles.memberId, memberId)) + : eq(organizationRoles.organizationId, organizationId); + + const rows = await db + .select({ + id: organizationRoles.id, + memberId: organizationRoles.memberId, + role: organizationRoles.role, + departmentId: organizationRoles.departmentId, + firstName: members.firstName, + lastName: members.lastName, + }) + .from(organizationRoles) + .innerJoin(members, eq(organizationRoles.memberId, members.id)) + .where(conditions); + + return { roles: rows }; + }); + + app.post("/api/organization-roles", { preHandler: app.authenticate }, async (request, reply) => { + const parsed = assignRoleSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { organizationId, memberId, role, departmentId } = parsed.data; + + const isAdmin = await requesterIsOrganizationAdmin(request.user.sub, organizationId); + if (!isAdmin) { + return reply.code(403).send({ error: "Forbidden" }); + } + + if (role === "ORG_ADMIN") { + const requesterIsFullAdmin = await requesterHasRole(request.user.sub, organizationId, "ORG_ADMIN"); + if (!requesterIsFullAdmin) { + return reply.code(403).send({ error: "Only an ORG_ADMIN can grant the ORG_ADMIN role" }); + } + } + + const [targetMember] = await db + .select({ id: members.id }) + .from(members) + .where(and(eq(members.id, memberId), eq(members.organizationId, organizationId))) + .limit(1); + if (!targetMember) { + return reply.code(404).send({ error: "Member not found in this organization" }); + } + + const [assignment] = await db + .insert(organizationRoles) + .values({ organizationId, memberId, role, departmentId }) + .returning(); + + await logAudit({ + organizationId, + actorUserId: request.user.sub, + action: "role.assigned", + targetType: "member", + targetId: memberId, + metadata: { role, departmentId }, + }); + + return reply.code(201).send(assignment); + }); + + app.delete( + "/api/organization-roles/:id", + { preHandler: app.authenticate }, + async (request, reply) => { + const { id } = request.params as { id: string }; + + const [assignment] = await db + .select() + .from(organizationRoles) + .where(eq(organizationRoles.id, id)) + .limit(1); + if (!assignment) { + return reply.code(404).send({ error: "Role assignment not found" }); + } + + const isAdmin = await requesterIsOrganizationAdmin(request.user.sub, assignment.organizationId); + if (!isAdmin) { + return reply.code(403).send({ error: "Forbidden" }); + } + + if (assignment.role === "ORG_ADMIN") { + const requesterIsFullAdmin = await requesterHasRole( + request.user.sub, + assignment.organizationId, + "ORG_ADMIN", + ); + if (!requesterIsFullAdmin) { + return reply.code(403).send({ error: "Only an ORG_ADMIN can revoke the ORG_ADMIN role" }); + } + } + + await db.delete(organizationRoles).where(eq(organizationRoles.id, id)); + + await logAudit({ + organizationId: assignment.organizationId, + actorUserId: request.user.sub, + action: "role.revoked", + targetType: "member", + targetId: assignment.memberId, + metadata: { role: assignment.role }, + }); + + return reply.code(204).send(); + }, + ); +} diff --git a/backend/src/routes/seasons.ts b/backend/src/routes/seasons.ts new file mode 100644 index 0000000..474d0cc --- /dev/null +++ b/backend/src/routes/seasons.ts @@ -0,0 +1,59 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { eq } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { seasons } from "../db/schema.js"; +import { requesterIsOrganizationMember, requesterIsOrganizationAdmin } from "../lib/authz.js"; + +const listQuerySchema = z.object({ + organizationId: z.string().uuid(), +}); + +const createSeasonSchema = z.object({ + organizationId: z.string().uuid(), + name: z.string().min(1), + startDate: z.string().date(), + endDate: z.string().date(), +}); + +export async function seasonRoutes(app: FastifyInstance) { + app.get("/api/seasons", { preHandler: app.authenticate }, async (request, reply) => { + const parsed = listQuerySchema.safeParse(request.query); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { organizationId } = parsed.data; + + const isMember = await requesterIsOrganizationMember(request.user.sub, organizationId); + if (!isMember) { + return reply.code(403).send({ error: "Forbidden" }); + } + + const rows = await db + .select() + .from(seasons) + .where(eq(seasons.organizationId, organizationId)) + .orderBy(seasons.startDate); + + return { seasons: rows }; + }); + + app.post("/api/seasons", { preHandler: app.authenticate }, async (request, reply) => { + const parsed = createSeasonSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { organizationId, name, startDate, endDate } = parsed.data; + + const isAdmin = await requesterIsOrganizationAdmin(request.user.sub, organizationId); + if (!isAdmin) { + return reply.code(403).send({ error: "Forbidden" }); + } + + const [season] = await db + .insert(seasons) + .values({ organizationId, name, startDate, endDate }) + .returning(); + return reply.code(201).send(season); + }); +} diff --git a/backend/src/routes/synapse.ts b/backend/src/routes/synapse.ts new file mode 100644 index 0000000..46f9e84 --- /dev/null +++ b/backend/src/routes/synapse.ts @@ -0,0 +1,9 @@ +import type { FastifyInstance } from "fastify"; +import { healthCheck } from "../services/synapse.js"; + +export async function synapseRoutes(app: FastifyInstance) { + app.get("/api/synapse/health", { preHandler: app.authenticate }, async () => { + const ok = await healthCheck(); + return { status: ok ? "ok" : "unreachable" }; + }); +} diff --git a/backend/src/routes/teams.ts b/backend/src/routes/teams.ts new file mode 100644 index 0000000..932e99a --- /dev/null +++ b/backend/src/routes/teams.ts @@ -0,0 +1,192 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { and, eq } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { teams, members, chatRooms, teamMembers } from "../db/schema.js"; +import { requesterIsOrganizationAdmin, requesterIsOrganizationMember } from "../lib/authz.js"; +import { createRoom, addUserToRoom, removeUserFromRoom } from "../services/synapse.js"; +import { enqueueSyncJob } from "../services/sync-worker.js"; +import { logAudit } from "../lib/audit.js"; + +const listQuerySchema = z.object({ + organizationId: z.string().uuid(), +}); + +const createTeamSchema = z.object({ + organizationId: z.string().uuid(), + departmentId: z.string().uuid(), + seasonId: z.string().uuid(), + name: z.string().min(1), +}); + +const addTeamMemberSchema = z.object({ + memberId: z.string().uuid(), +}); + +async function requesterIsAdminForTeam(userId: string, teamId: string): Promise { + const [team] = await db + .select({ organizationId: teams.organizationId }) + .from(teams) + .where(eq(teams.id, teamId)) + .limit(1); + if (!team) return null; + const isAdmin = await requesterIsOrganizationAdmin(userId, team.organizationId); + return isAdmin ? team.organizationId : null; +} + +export async function teamRoutes(app: FastifyInstance) { + app.get("/api/teams", { preHandler: app.authenticate }, async (request, reply) => { + const parsed = listQuerySchema.safeParse(request.query); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { organizationId } = parsed.data; + + const isMember = await requesterIsOrganizationMember(request.user.sub, organizationId); + if (!isMember) { + return reply.code(403).send({ error: "Forbidden" }); + } + + const rows = await db + .select({ + id: teams.id, + name: teams.name, + departmentId: teams.departmentId, + seasonId: teams.seasonId, + createdAt: teams.createdAt, + }) + .from(teams) + .where(eq(teams.organizationId, organizationId)) + .orderBy(teams.name); + + return { teams: rows }; + }); + + app.post("/api/teams", { preHandler: app.authenticate }, async (request, reply) => { + const parsed = createTeamSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { organizationId, departmentId, seasonId, name } = parsed.data; + + const isAdmin = await requesterIsOrganizationAdmin(request.user.sub, organizationId); + if (!isAdmin) { + return reply.code(403).send({ error: "Forbidden" }); + } + + const [team] = await db + .insert(teams) + .values({ organizationId, departmentId, seasonId, name }) + .returning(); + + try { + const { roomId } = await createRoom({ name: team.name }); + await db.insert(chatRooms).values({ + organizationId, + matrixRoomId: roomId, + name: team.name, + scope: "team", + departmentId, + teamId: team.id, + }); + } catch (err) { + console.warn(`[synapse] inline room creation failed for team ${team.id}, queuing retry:`, err); + await enqueueSyncJob(organizationId, "create_team_room", { teamId: team.id }); + } + + await logAudit({ + organizationId, + actorUserId: request.user.sub, + action: "team.created", + targetType: "team", + targetId: team.id, + metadata: { name: team.name, departmentId, seasonId }, + }); + + return reply.code(201).send(team); + }); + + app.post( + "/api/teams/:teamId/members", + { preHandler: app.authenticate }, + async (request, reply) => { + const { teamId } = request.params as { teamId: string }; + const parsed = addTeamMemberSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { memberId } = parsed.data; + + const organizationId = await requesterIsAdminForTeam(request.user.sub, teamId); + if (!organizationId) { + return reply.code(403).send({ error: "Forbidden" }); + } + + await db.insert(teamMembers).values({ teamId, memberId }); + + const [room] = await db + .select({ matrixRoomId: chatRooms.matrixRoomId }) + .from(chatRooms) + .where(eq(chatRooms.teamId, teamId)) + .limit(1); + const [member] = await db + .select({ matrixUserId: members.matrixUserId }) + .from(members) + .where(eq(members.id, memberId)) + .limit(1); + + if (room && member?.matrixUserId) { + try { + await addUserToRoom(room.matrixRoomId, member.matrixUserId); + } catch (err) { + console.warn(`[synapse] inline room join failed for member ${memberId}, queuing retry:`, err); + await enqueueSyncJob(organizationId, "join_team_room", { teamId, memberId }); + } + } else { + // Room or matrix account not provisioned yet — worker retries once both exist. + await enqueueSyncJob(organizationId, "join_team_room", { teamId, memberId }); + } + + return reply.code(201).send({ teamId, memberId }); + }, + ); + + app.delete( + "/api/teams/:teamId/members/:memberId", + { preHandler: app.authenticate }, + async (request, reply) => { + const { teamId, memberId } = request.params as { teamId: string; memberId: string }; + + const organizationId = await requesterIsAdminForTeam(request.user.sub, teamId); + if (!organizationId) { + return reply.code(403).send({ error: "Forbidden" }); + } + + await db + .delete(teamMembers) + .where(and(eq(teamMembers.teamId, teamId), eq(teamMembers.memberId, memberId))); + + const [room] = await db + .select({ matrixRoomId: chatRooms.matrixRoomId }) + .from(chatRooms) + .where(eq(chatRooms.teamId, teamId)) + .limit(1); + const [member] = await db + .select({ matrixUserId: members.matrixUserId }) + .from(members) + .where(eq(members.id, memberId)) + .limit(1); + + if (room && member?.matrixUserId) { + try { + await removeUserFromRoom(room.matrixRoomId, member.matrixUserId); + } catch (err) { + console.warn(`[synapse] inline room leave failed for member ${memberId}, queuing retry:`, err); + await enqueueSyncJob(organizationId, "leave_team_room", { teamId, memberId }); + } + } + + return reply.code(204).send(); + }, + ); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index acb86fe..a2664f8 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -5,6 +5,17 @@ import jwt from "@fastify/jwt"; import rateLimit from "@fastify/rate-limit"; import { healthRoutes } from "./routes/health.js"; import { authRoutes } from "./routes/auth.js"; +import { dashboardRoutes } from "./routes/dashboard.js"; +import { synapseRoutes } from "./routes/synapse.js"; +import { organizationRoutes } from "./routes/organizations.js"; +import { invitationRoutes } from "./routes/invitations.js"; +import { memberRoutes } from "./routes/members.js"; +import { teamRoutes } from "./routes/teams.js"; +import { departmentRoutes } from "./routes/departments.js"; +import { seasonRoutes } from "./routes/seasons.js"; +import { roleRoutes } from "./routes/roles.js"; +import { auditRoutes } from "./routes/audit.js"; +import { startSyncWorker, startReconciliationWorker } from "./services/sync-worker.js"; const app = Fastify({ logger: true }); @@ -22,9 +33,27 @@ app.decorate("authenticate", async (request: FastifyRequest, reply: FastifyReply await app.register(healthRoutes); await app.register(authRoutes); +await app.register(dashboardRoutes); +await app.register(synapseRoutes); +await app.register(organizationRoutes); +await app.register(invitationRoutes); +await app.register(memberRoutes); +await app.register(teamRoutes); +await app.register(departmentRoutes); +await app.register(seasonRoutes); +await app.register(roleRoutes); +await app.register(auditRoutes); const port = Number(process.env.PORT ?? 4000); app.listen({ port, host: "0.0.0.0" }).catch((err) => { app.log.error(err); process.exit(1); }); + +const syncWorkerInterval = startSyncWorker(); +const reconciliationInterval = startReconciliationWorker(); +app.addHook("onClose", (_instance, done) => { + clearInterval(syncWorkerInterval); + clearInterval(reconciliationInterval); + done(); +}); diff --git a/backend/src/services/mail.ts b/backend/src/services/mail.ts new file mode 100644 index 0000000..52917e3 --- /dev/null +++ b/backend/src/services/mail.ts @@ -0,0 +1,131 @@ +import { connect as netConnect, type Socket } from "node:net"; +import { connect as tlsConnect, type TLSSocket } from "node:tls"; + +const smtpHost = process.env.SMTP_HOST; +const smtpPort = Number(process.env.SMTP_PORT ?? 587); +const smtpUser = process.env.SMTP_USER; +const smtpPassword = process.env.SMTP_PASSWORD; +const mailFrom = process.env.MAIL_FROM ?? "no-reply@vereinschat.local"; + +type SmtpSocket = Socket | TLSSocket; + +function readResponse(socket: SmtpSocket): Promise { + return new Promise((resolve, reject) => { + let buffer = ""; + function onData(chunk: Buffer) { + buffer += chunk.toString("utf8"); + // Multi-line responses use "250-", the final line uses "250 " — wait for that. + const lines = buffer.split("\r\n").filter(Boolean); + const last = lines[lines.length - 1]; + if (last && /^\d{3} /.test(last)) { + cleanup(); + resolve(buffer); + } + } + function onError(err: Error) { + cleanup(); + reject(err); + } + function cleanup() { + socket.off("data", onData); + socket.off("error", onError); + } + socket.on("data", onData); + socket.on("error", onError); + }); +} + +async function sendCommand(socket: SmtpSocket, command: string): Promise { + socket.write(`${command}\r\n`); + return readResponse(socket); +} + +function assertOk(response: string, context: string) { + const code = Number(response.slice(0, 3)); + if (code >= 400) { + throw new Error(`SMTP error during ${context}: ${response.trim()}`); + } +} + +async function upgradeToTls(socket: Socket, host: string): Promise { + return new Promise((resolve, reject) => { + const tlsSocket = tlsConnect({ socket, host, servername: host }, () => resolve(tlsSocket)); + tlsSocket.on("error", reject); + }); +} + +export async function sendMail(params: { + to: string; + subject: string; + text: string; + html?: string; +}): Promise { + if (!smtpHost) { + // No SMTP configured (local dev without mail server) — log instead of failing the request. + console.warn(`[mail] SMTP not configured, skipping send to ${params.to}: ${params.subject}`); + return; + } + + const useImplicitTls = smtpPort === 465; + + let socket: SmtpSocket = await new Promise((resolve, reject) => { + const s = useImplicitTls + ? tlsConnect({ host: smtpHost, port: smtpPort, servername: smtpHost }, () => resolve(s)) + : netConnect({ host: smtpHost, port: smtpPort }, () => resolve(s)); + s.on("error", reject); + }); + + await readResponse(socket); // greeting + + let response = await sendCommand(socket, `EHLO vereinschat.local`); + assertOk(response, "EHLO"); + + if (!useImplicitTls && /STARTTLS/i.test(response)) { + response = await sendCommand(socket, "STARTTLS"); + assertOk(response, "STARTTLS"); + socket = await upgradeToTls(socket as Socket, smtpHost); + response = await sendCommand(socket, `EHLO vereinschat.local`); + assertOk(response, "EHLO after STARTTLS"); + } + + if (smtpUser && smtpPassword) { + response = await sendCommand(socket, "AUTH LOGIN"); + assertOk(response, "AUTH LOGIN"); + response = await sendCommand(socket, Buffer.from(smtpUser).toString("base64")); + assertOk(response, "AUTH username"); + response = await sendCommand(socket, Buffer.from(smtpPassword).toString("base64")); + assertOk(response, "AUTH password"); + } + + response = await sendCommand(socket, `MAIL FROM:<${extractEmail(mailFrom)}>`); + assertOk(response, "MAIL FROM"); + + response = await sendCommand(socket, `RCPT TO:<${params.to}>`); + assertOk(response, "RCPT TO"); + + response = await sendCommand(socket, "DATA"); + assertOk(response, "DATA"); + + const body = params.html + ? `Content-Type: text/html; charset=utf-8\r\n\r\n${params.html}` + : `Content-Type: text/plain; charset=utf-8\r\n\r\n${params.text}`; + + const message = [ + `From: ${mailFrom}`, + `To: ${params.to}`, + `Subject: ${params.subject}`, + body, + ".", + ].join("\r\n"); + + response = await sendCommand(socket, message); + assertOk(response, "message body"); + + await sendCommand(socket, "QUIT"); + socket.end(); +} + +function extractEmail(fromHeader: string): string { + const match = fromHeader.match(/<(.+)>/); + return match ? match[1] : fromHeader; +} diff --git a/backend/src/services/synapse.ts b/backend/src/services/synapse.ts new file mode 100644 index 0000000..f0cc83d --- /dev/null +++ b/backend/src/services/synapse.ts @@ -0,0 +1,138 @@ +const baseUrl = process.env.SYNAPSE_BASE_URL!; +const adminToken = process.env.SYNAPSE_ADMIN_TOKEN!; +const serverName = process.env.SYNAPSE_SERVER_NAME!; + +export class SynapseError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + this.name = "SynapseError"; + } +} + +async function synapseFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${adminToken}`, + ...init?.headers, + }, + }); + + if (!res.ok) { + const body = await res.text(); + throw new SynapseError(`Synapse request failed (${path}): ${body}`, res.status); + } + + if (res.status === 204) { + return undefined as T; + } + return (await res.json()) as T; +} + +export function toMatrixUserId(localpart: string): string { + return `@${localpart}:${serverName}`; +} + +export function toLocalpart(firstName: string, lastName: string): string { + const normalize = (part: string) => + part + .toLowerCase() + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .replace(/[^a-z0-9]/g, ""); + return `${normalize(firstName)}.${normalize(lastName)}`; +} + +export interface SynapseUser { + name: string; + displayname: string | null; + admin: boolean; + deactivated: boolean; +} + +export async function healthCheck(): Promise { + try { + const res = await fetch(`${baseUrl}/health`); + return res.ok; + } catch { + return false; + } +} + +export async function createUser( + localpart: string, + params: { password: string; displayName?: string }, +): Promise { + const userId = toMatrixUserId(localpart); + return synapseFetch(`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, { + method: "PUT", + body: JSON.stringify({ + password: params.password, + displayname: params.displayName, + }), + }); +} + +export async function getUser(userId: string): Promise { + try { + return await synapseFetch( + `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, + ); + } catch (err) { + if (err instanceof SynapseError && err.status === 404) { + return null; + } + throw err; + } +} + +export async function deactivateUser(userId: string, erase = false): Promise { + await synapseFetch(`/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`, { + method: "POST", + body: JSON.stringify({ erase }), + }); +} + +export async function reactivateUser(userId: string, newPassword: string): Promise { + await synapseFetch(`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, { + method: "PUT", + body: JSON.stringify({ deactivated: false, password: newPassword }), + }); +} + +export interface CreateRoomResult { + roomId: string; +} + +export async function createRoom(params: { + name: string; + alias?: string; +}): Promise { + const result = await synapseFetch<{ room_id: string }>("/_matrix/client/v3/createRoom", { + method: "POST", + body: JSON.stringify({ + name: params.name, + room_alias_name: params.alias, + preset: "private_chat", + }), + }); + return { roomId: result.room_id }; +} + +export async function addUserToRoom(roomId: string, userId: string): Promise { + await synapseFetch(`/_synapse/admin/v1/join/${encodeURIComponent(roomId)}`, { + method: "POST", + body: JSON.stringify({ user_id: userId }), + }); +} + +export async function removeUserFromRoom(roomId: string, userId: string): Promise { + await synapseFetch(`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`, { + method: "POST", + body: JSON.stringify({ user_id: userId, reason: "Removed from TalkTime group" }), + }); +} diff --git a/backend/src/services/sync-worker.ts b/backend/src/services/sync-worker.ts new file mode 100644 index 0000000..7a21a40 --- /dev/null +++ b/backend/src/services/sync-worker.ts @@ -0,0 +1,274 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, lt, or, isNull, isNotNull } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { syncJobs, members, chatRooms, teams } from "../db/schema.js"; +import { + createUser, + createRoom, + addUserToRoom, + removeUserFromRoom, + getUser, + toMatrixUserId, + toLocalpart, + SynapseError, +} from "./synapse.js"; +import { logAudit } from "../lib/audit.js"; + +const MAX_ATTEMPTS = 5; + +type SyncJobPayload = { + provision_matrix_user: { memberId: string; firstName: string; lastName: string }; + create_team_room: { teamId: string }; + join_team_room: { teamId: string; memberId: string }; + leave_team_room: { teamId: string; memberId: string }; +}; + +type SyncJobType = keyof SyncJobPayload; + +const handlers: { + [K in SyncJobType]: (payload: SyncJobPayload[K]) => Promise; +} = { + provision_matrix_user: async ({ memberId, firstName, lastName }) => { + const [member] = await db + .select({ matrixUserId: members.matrixUserId }) + .from(members) + .where(eq(members.id, memberId)) + .limit(1); + if (!member) { + throw new Error(`Member ${memberId} no longer exists`); + } + if (member.matrixUserId) { + return; // already provisioned, idempotent no-op + } + + const base = toLocalpart(firstName, lastName); + const password = randomUUID().replace(/-/g, ""); + const displayName = `${firstName} ${lastName}`; + + for (let suffix = 0; suffix < 10; suffix++) { + const localpart = suffix === 0 ? base : `${base}${suffix + 1}`; + try { + await createUser(localpart, { password, displayName }); + await db + .update(members) + .set({ matrixUserId: toMatrixUserId(localpart) }) + .where(eq(members.id, memberId)); + return; + } catch (err) { + if (err instanceof SynapseError && err.status === 400) { + continue; // localpart taken, try next suffix + } + throw err; + } + } + throw new Error(`Exhausted localpart suffixes for member ${memberId}`); + }, + + create_team_room: async ({ teamId }) => { + const [existingRoom] = await db + .select({ id: chatRooms.id }) + .from(chatRooms) + .where(eq(chatRooms.teamId, teamId)) + .limit(1); + if (existingRoom) { + return; // already provisioned, idempotent no-op + } + + const [team] = await db + .select({ id: teams.id, organizationId: teams.organizationId, departmentId: teams.departmentId, name: teams.name }) + .from(teams) + .where(eq(teams.id, teamId)) + .limit(1); + if (!team) { + throw new Error(`Team ${teamId} no longer exists`); + } + + const { roomId } = await createRoom({ name: team.name }); + await db.insert(chatRooms).values({ + organizationId: team.organizationId, + matrixRoomId: roomId, + name: team.name, + scope: "team", + departmentId: team.departmentId, + teamId: team.id, + }); + }, + + join_team_room: async ({ teamId, memberId }) => { + const [room] = await db + .select({ matrixRoomId: chatRooms.matrixRoomId }) + .from(chatRooms) + .where(eq(chatRooms.teamId, teamId)) + .limit(1); + if (!room) { + throw new Error(`No chat room provisioned yet for team ${teamId}`); + } + + const [member] = await db + .select({ matrixUserId: members.matrixUserId }) + .from(members) + .where(eq(members.id, memberId)) + .limit(1); + if (!member?.matrixUserId) { + throw new Error(`Member ${memberId} has no matrix account yet`); + } + + await addUserToRoom(room.matrixRoomId, member.matrixUserId); + }, + + leave_team_room: async ({ teamId, memberId }) => { + const [room] = await db + .select({ matrixRoomId: chatRooms.matrixRoomId }) + .from(chatRooms) + .where(eq(chatRooms.teamId, teamId)) + .limit(1); + if (!room) { + return; // no room ever provisioned, nothing to leave + } + + const [member] = await db + .select({ matrixUserId: members.matrixUserId }) + .from(members) + .where(eq(members.id, memberId)) + .limit(1); + if (!member?.matrixUserId) { + return; // member was never provisioned, nothing to remove + } + + await removeUserFromRoom(room.matrixRoomId, member.matrixUserId); + }, +}; + +export async function enqueueSyncJob( + organizationId: string, + type: T, + payload: SyncJobPayload[T], +): Promise { + await db.insert(syncJobs).values({ + organizationId, + type, + payload: JSON.stringify(payload), + }); +} + +function backoffDelayMs(attempts: number): number { + return Math.min(60_000 * 2 ** attempts, 30 * 60_000); // 1min, 2min, 4min... capped at 30min +} + +export async function processPendingSyncJobs(): Promise { + const now = new Date(); + const pending = await db + .select() + .from(syncJobs) + .where( + and( + eq(syncJobs.status, "pending"), + or(isNull(syncJobs.updatedAt), lt(syncJobs.updatedAt, now)), + ), + ) + .limit(20); + + for (const job of pending) { + const dueAt = new Date(job.updatedAt.getTime() + backoffDelayMs(job.attempts)); + if (job.attempts > 0 && dueAt > now) { + continue; // still in backoff window + } + + await db + .update(syncJobs) + .set({ status: "processing", updatedAt: new Date() }) + .where(eq(syncJobs.id, job.id)); + + try { + const handler = handlers[job.type as SyncJobType]; + if (!handler) { + throw new Error(`Unknown sync job type: ${job.type}`); + } + await handler(JSON.parse(job.payload)); + await db + .update(syncJobs) + .set({ status: "done", updatedAt: new Date(), lastError: null }) + .where(eq(syncJobs.id, job.id)); + } catch (err) { + const attempts = job.attempts + 1; + const failed = attempts >= MAX_ATTEMPTS; + await db + .update(syncJobs) + .set({ + status: failed ? "failed" : "pending", + attempts, + lastError: (err as Error).message, + updatedAt: new Date(), + }) + .where(eq(syncJobs.id, job.id)); + } + } +} + +export function startSyncWorker(intervalMs = 30_000): NodeJS.Timeout { + return setInterval(() => { + processPendingSyncJobs().catch((err) => { + console.error("[sync-worker] failed to process jobs:", err); + }); + }, intervalMs); +} + +/** + * Compares TalkTime's view of provisioned members against Synapse's actual + * state and logs discrepancies (e.g. "member active here, deactivated/missing + * on Synapse"). Read-only — corrective action is manual for now via the audit log. + */ +export async function runReconciliation(): Promise { + const provisioned = await db + .select({ + id: members.id, + organizationId: members.organizationId, + status: members.status, + matrixUserId: members.matrixUserId, + }) + .from(members) + .where(isNotNull(members.matrixUserId)); + + for (const member of provisioned) { + if (!member.matrixUserId) continue; + + let synapseUser; + try { + synapseUser = await getUser(member.matrixUserId); + } catch (err) { + console.warn(`[reconciliation] failed to check ${member.matrixUserId}:`, err); + continue; + } + + const expectedDeactivated = member.status !== "active"; + const synapseState = synapseUser === null + ? "missing" + : synapseUser.deactivated + ? "deactivated" + : "active"; + const mismatch = synapseUser === null || synapseUser.deactivated !== expectedDeactivated; + + if (mismatch) { + await logAudit({ + organizationId: member.organizationId, + actorUserId: null, + action: "sync.discrepancy", + targetType: "member", + targetId: member.id, + metadata: { + matrixUserId: member.matrixUserId, + localStatus: member.status, + synapseState, + }, + }); + } + } +} + +export function startReconciliationWorker(intervalMs = 10 * 60_000): NodeJS.Timeout { + return setInterval(() => { + runReconciliation().catch((err) => { + console.error("[reconciliation] failed:", err); + }); + }, intervalMs); +} diff --git a/claude.md b/claude.md new file mode 100644 index 0000000..725d773 --- /dev/null +++ b/claude.md @@ -0,0 +1,1433 @@ +MASTER-PROMPT: VereinsChat – Kommunikationsplattform für Sportvereine +1. Rolle + +Du bist ein Senior Software Architect, Product Engineer, Security Engineer und DevOps Engineer. + +Du arbeitest in einem bestehenden Repository auf Basis des Projekts: + +https://gitea.perlbach24.de/scripte/ai-coding-starter-kit-v2 + +Das bestehende Starter-Kit ist die technische Grundlage des Projekts. + +Das Ziel ist der Aufbau einer professionellen, selbst hostbaren Kommunikations- und Verwaltungsplattform für Sportvereine. + +Arbeitstitel des Produkts: +VereinsChat + +VereinsChat soll eine datenschutzfreundliche Alternative zu WhatsApp für Sportvereine werden. + +Die eigentliche Kommunikation soll auf Matrix/Synapse basieren. + +Für die Administration und Vereinslogik soll eine eigene Anwendung über der Synapse-Infrastruktur aufgebaut werden. +2. Wichtigste Regel + +Arbeite NICHT sofort blind mit der Implementierung los. + +Zuerst: + + Repository vollständig analysieren. + Bestehende Architektur verstehen. + Bestehende Skills, Agents, Regeln und Workflows verstehen. + Vorhandene Funktionalität identifizieren. + Anforderungen für VereinsChat ausarbeiten. + Architektur definieren. + Datenmodell definieren. + API definieren. + Berechtigungsmodell definieren. + Synapse-Integration definieren. + Sicherheitsmodell definieren. + Deployment definieren. + Teststrategie definieren. + Feature-Roadmap erstellen. + Erst danach mit der Implementierung beginnen. + +Bestehende Funktionen des Starter-Kits dürfen nicht unnötig überschrieben oder zerstört werden. + +Nutze vorhandene Patterns des Starter-Kits. +3. Produktvision + +VereinsChat soll die zentrale digitale Kommunikationsplattform eines Sportvereins werden. + +Der Verein soll damit: + + Mitglieder verwalten + Abteilungen verwalten + Mannschaften verwalten + Trainer verwalten + Betreuer verwalten + Rollen verwalten + Einladungen verschicken + Kommunikationsgruppen erstellen + Mitglieder automatisch Gruppen zuordnen + Matrix/Synapse-Benutzer verwalten + Matrix-Räume verwalten + Vereinskommunikation durchführen + +können. + +Der Benutzer soll möglichst wenig von Matrix oder Synapse wissen müssen. + +Die technische Matrix-Infrastruktur soll im Hintergrund arbeiten. + +Der Benutzer denkt in: + + Verein + Abteilung + Mannschaft + Mitglied + Rolle + Gruppe + +und nicht in: + + Matrix Homeserver + Room IDs + Matrix IDs + Synapse Admin API + +4. Technische Grundarchitektur + +Verwende grundsätzlich folgende Architektur: + + ┌─────────────────────────┐ + │ VereinsChat │ + │ Web Frontend │ + │ React + TypeScript │ + └────────────┬────────────┘ + │ + │ HTTPS + ▼ + ┌─────────────────────────┐ + │ VereinsChat API │ + │ Fastify + TypeScript │ + │ │ + │ Auth │ + │ Vereine │ + │ Mitglieder │ + │ Mannschaften │ + │ Rollen │ + │ Gruppen │ + │ Invitations │ + │ Synapse Integration │ + └──────┬───────────┬──────┘ + │ │ + ▼ ▼ + PostgreSQL Synapse + │ + ▼ + Matrix + +PostgreSQL enthält die Vereins- und Verwaltungsdaten. + +Synapse ist das Messaging-Backend. + +Nachrichten sollen NICHT zusätzlich in der VereinsChat-Datenbank gespeichert werden. + +Unsere Datenbank speichert lediglich die Zuordnung zwischen Vereinsobjekten und Matrix-Objekten. +5. Starter-Kit analysieren + +Untersuche zuerst das komplette Repository. + +Analysiere insbesondere: + + package.json + Frontend-Struktur + Backend-Struktur + Datenbank + Drizzle + Fastify + React + Vite + Tailwind + shadcn/ui + Authentifizierung + vorhandene API-Struktur + vorhandene Komponenten + vorhandene Hooks + vorhandene Tests + Docker + CI/CD + Dokumentation + .claude + Agents + Skills + Rules + Features + vorhandene PRDs + +Nutze die vorhandene Projektstruktur als Grundlage. + +Wenn das Starter-Kit bereits eine Funktionalität besitzt, die wir benötigen, soll diese wiederverwendet und nicht neu erfunden werden. +6. Product Requirements Document + +Erstelle bzw. erweitere: + +docs/PRD.md + +Das PRD muss VereinsChat vollständig beschreiben. + +Es soll mindestens folgende Kapitel enthalten: +Produkt + + Produktname + Vision + Zielgruppe + Problem + Lösung + USP + Zielmärkte + Abgrenzung zu WhatsApp + Abgrenzung zu klassischen Vereinssoftwares + +Benutzer + +Beschreibe: + + Vereinsadministrator + Vereinsmanager + Abteilungsleiter + Mannschaftsleiter + Trainer + Co-Trainer + Betreuer + Spieler + Mitglied + Eltern/Erziehungsberechtigte + Systemadministrator + +Prüfe, welche Rollen tatsächlich benötigt werden. +7. Vereinsstruktur + +Das System muss hierarchische Vereinsstrukturen unterstützen. + +Beispiel: + +Sportverein +│ +├── Abteilung Fußball +│ │ +│ ├── 1. Herren +│ ├── 2. Herren +│ ├── A-Jugend +│ ├── B-Jugend +│ └── C-Jugend +│ +├── Abteilung Handball +│ │ +│ ├── Damen +│ └── Herren +│ +└── Vorstand + +Definiere: + + Verein + Abteilung + Mannschaft + Saison + Mitgliedschaft + Rollen + Zuständigkeiten + +8. Mitgliederverwaltung + +Entwickle ein vollständiges Konzept für Mitglieder. + +Ein Mitglied soll beispielsweise folgende Eigenschaften besitzen: + +Vorname +Nachname +E-Mail +Telefon +Geburtsdatum +Status +Rolle +Abteilung +Mannschaft +Matrix User ID + +Prüfe insbesondere: + + Minderjährige + Eltern + mehrere Mannschaften + mehrere Rollen + Vereinswechsel + Austritt + Sperrung + Löschung + Wiedereintritt + +Datenschutz muss berücksichtigt werden. +9. Matrix / Synapse Integration + +Synapse ist das Messaging-System. + +Entwickle eine saubere Integration über die Synapse Admin API. + +Wichtig: + +Der Browser darf niemals direkt auf die Synapse Admin API zugreifen. + +Stattdessen: + +Browser + │ + ▼ +VereinsChat Backend + │ + ▼ +Synapse Admin API + +Das Synapse Admin Token darf niemals an den Client gelangen. + +Definiere eine eigene: + +SynapseService + +bzw. geeignete Service-/Adapter-Struktur. + +Diese muss beispielsweise unterstützen: + + Benutzer anlegen + Benutzer suchen + Benutzer deaktivieren + Benutzer aktivieren + Benutzerinformationen abrufen + Räume erstellen + Räume verwalten + Benutzer zu Räumen hinzufügen + Benutzer aus Räumen entfernen + Rollen/Berechtigungen verwalten + Synchronisation + Health Check + +Nutze die offizielle aktuelle Synapse-Dokumentation als Referenz. + +Wenn sich APIs zwischen Synapse-Versionen unterscheiden, dokumentiere die verwendete Version. +10. Matrix User Provisioning + +Wenn ein Vereinsmitglied einen Account erhält: + +Vereinsmitglied + │ + ▼ +VereinsChat Account + │ + ▼ +Matrix Account + +Die Matrix-ID soll nach einem definierten Schema erzeugt werden. + +Beispiel: + +@max.mustermann:chat.vereinschat.de + +Entwickle Regeln für: + + Namenskonflikte + Umbenennung + E-Mail-Änderung + Löschung + Deaktivierung + Wiederaktivierung + Datenschutz + Migration + +Matrix User IDs dürfen nicht leichtfertig geändert werden. +11. Chatgruppen / Räume + +VereinsChat soll automatisch Kommunikationsräume verwalten. + +Beispiel: + +SV Musterstadt + +#verein +#vorstand +#fussball +#fussball-1-herren +#fussball-2-herren +#fussball-a-jugend +#handball +#handball-damen + +Entwickle Regeln dafür, wann ein Raum automatisch erstellt wird. + +Beispiel: + +Mannschaft erstellt + ↓ +Matrix Raum erstellen + ↓ +Trainer hinzufügen + ↓ +Mitglieder hinzufügen + +Wenn ein Mitglied aus einer Mannschaft entfernt wird: + +Mitglied entfernt + ↓ +VereinsChat aktualisieren + ↓ +Matrix Membership aktualisieren + +12. Synchronisierung + +Entwickle eine robuste Synchronisationslogik. + +Es muss verhindert werden, dass beispielsweise: + +VereinsChat sagt: +Max ist Mitglied + +Synapse sagt: +Max existiert nicht + +oder: + +VereinsChat: +Max ist nicht mehr in Mannschaft A + +Synapse: +Max ist weiterhin Mitglied im Raum + +Entwickle deshalb: + + Sync Jobs + Reconciliation + Retry + Idempotenz + Fehlerzustände + Audit Logs + manuellen Resync + Health Checks + +13. Rollen und Berechtigungen + +Implementiere ein sauberes RBAC-System. + +Beispiel: + +SYSTEM_ADMIN +CLUB_ADMIN +CLUB_MANAGER +DEPARTMENT_ADMIN +TEAM_MANAGER +COACH +ASSISTANT_COACH +SUPERVISOR +MEMBER +PARENT + +Definiere exakt: + + wer Benutzer anlegen darf + wer Benutzer löschen darf + wer Mannschaften erstellen darf + wer Gruppen erstellen darf + wer Mitglieder hinzufügen darf + wer Mitglieder entfernen darf + wer Rollen vergeben darf + wer Vereinsdaten bearbeiten darf + +Verhindere Privilege Escalation. + +Ein Trainer darf beispielsweise nicht automatisch Vereinsadministrator werden können. +14. Multi-Tenant Architektur + +VereinsChat soll mehrere Vereine unterstützen. + +Beispiel: + +VereinsChat +│ +├── Verein A +├── Verein B +├── Verein C +└── Verein D + +Alle Daten müssen tenant-sicher getrennt sein. + +Definiere: + + Tenant ID + Datenbankmodell + Authorization + API Guards + Query Scoping + Audit Logs + Tenant Isolation + +Ein Benutzer darf niemals Daten eines anderen Vereins sehen können. +15. Dashboard + +Entwickle ein modernes Admin Dashboard. + +Es soll mindestens enthalten: + +Dashboard + +Mitglieder +Abteilungen +Mannschaften +Chatgruppen +Einladungen +Aktivitäten +Systemstatus + +Beispiel: + +┌─────────────────────────────────────────────┐ +│ SV Musterstadt │ +├─────────────────────────────────────────────┤ +│ │ +│ Mitglieder 438 │ +│ Mannschaften 24 │ +│ Chatgruppen 31 │ +│ Einladungen 7 │ +│ │ +│ Letzte Aktivitäten │ +│ │ +└─────────────────────────────────────────────┘ + +16. Einladungen + +Entwickle ein vollständiges Invitation-System. + +Ablauf: + +Administrator + │ + ▼ +E-Mail-Adresse + │ + ▼ +Einladung + │ + ▼ +Link + │ + ▼ +Account erstellen + │ + ▼ +Matrix Account + │ + ▼ +Vereinsgruppen + +Definiere: + + Token + Ablaufzeit + Wiederverwendung + Widerruf + E-Mail + Status + Audit Log + Fehlerfälle + +17. Authentifizierung + +Untersuche zunächst die Authentifizierung des Starter-Kits. + +Erweitere sie nur, wenn erforderlich. + +Entwickle: + + Login + Logout + Session + Passwort Reset + E-Mail Verification + MFA als spätere Option + Rollenprüfung + +Die Matrix-Authentifizierung und die VereinsChat-Authentifizierung müssen sauber voneinander getrennt werden. + +Prüfe, ob OIDC sinnvoll ist. +18. Datenschutz / DSGVO + +Datenschutz ist ein Kernbestandteil des Projektes. + +Definiere: + + personenbezogene Daten + Zweckbindung + Datenminimierung + Löschung + Deaktivierung + Export + Auditierung + Aufbewahrungsfristen + Backups + Log-Daten + IP-Adressen + E-Mail-Adressen + Telefonnummern + +Insbesondere Minderjährige müssen berücksichtigt werden. + +Es darf keine Funktion gebaut werden, die personenbezogene Daten unnötig kopiert. +19. Sicherheit + +Führe ein Security Design durch. + +Berücksichtige: + + OWASP + SQL Injection + XSS + CSRF + SSRF + IDOR + Broken Access Control + Rate Limiting + Session Security + Secrets + API Tokens + Password Security + Audit Logs + Brute Force + Enumeration + Upload Security + +Das Synapse Admin Token darf niemals: + + im Frontend + im Browser + in Logs + in API Responses + in Git + +auftauchen. +20. Medien und Dateien + +Matrix soll für Kommunikation und Medien genutzt werden. + +Prüfe: + + Bilder + Videos + PDFs + Dokumente + Dateigrößen + MIME Types + Upload Limits + Virenscanner + Storage + Retention + +Entscheide, was durch Synapse und was durch VereinsChat verwaltet werden sollte. +21. Benachrichtigungen + +Konzipiere: + + In-App Notifications + Push + E-Mail + wichtige Vereinsnachrichten + Einladungen + Systemmeldungen + +Nicht jede Matrix-Nachricht muss zwingend durch das VereinsChat-Backend verarbeitet werden. + +Nutze Matrix-Funktionalität, wo sinnvoll. +22. Audit Log + +Entwickle ein Audit-System. + +Beispiel: + +Administrator + ↓ +Mitglied gelöscht + +Administrator + ↓ +Trainerrolle vergeben + +Administrator + ↓ +Mannschaft erstellt + +System + ↓ +Matrix Raum erstellt + +Audit Logs müssen nachvollziehbar und vor Manipulation geschützt sein. +23. Fehlerbehandlung + +Definiere Fehlerzustände zwischen: + +VereinsChat + ↕ +Synapse + +Beispiel: + +Raum konnte nicht erstellt werden + +Das darf nicht dazu führen, dass die Mannschaftserstellung komplett unkontrolliert fehlschlägt. + +Verwende: + + Transactions + Jobs + Retries + Outbox Pattern, falls sinnvoll + Idempotency + Statusfelder + +24. Datenmodell + +Erstelle ein vollständiges relationales Datenmodell. + +Mindestens prüfen: + +clubs +users +club_members +roles +permissions +departments +teams +seasons +team_members +invitations +chat_rooms +chat_room_memberships +synapse_users +sync_jobs +audit_logs +notifications + +Normalisiere sinnvoll. + +Nutze Drizzle ORM gemäß dem Starter-Kit. +25. API + +Entwickle eine konsistente REST API. + +Beispielsweise: + +/api/auth +/api/clubs +/api/members +/api/departments +/api/teams +/api/seasons +/api/invitations +/api/chat +/api/rooms +/api/synapse +/api/audit + +Definiere: + + Request + Response + Validation + Authorization + Errors + Pagination + Filtering + Sorting + +Verwende TypeScript-Typen und Schema Validation. +26. Frontend + +Verwende die im Starter-Kit vorhandene Technologie. + +Grundsätzlich: + + React + TypeScript + Vite + Tailwind + shadcn/ui + +Das UI soll professionell, modern und einfach bedienbar sein. + +Designziel: + +modern +ruhig +sportlich +übersichtlich +mobile-friendly +barrierearm + +Kein überladenes Enterprise-UI. +27. Chat Client + +Prüfe sorgfältig, wie der eigentliche Matrix-Chat integriert werden soll. + +Mögliche Strategien: + + vorhandenen Matrix Client integrieren + Matrix Client einbetten + eigene Chat-Oberfläche mit Matrix SDK + Kombination + +Vergleiche die Varianten hinsichtlich: + + Entwicklungsaufwand + Sicherheit + UX + E2EE + Wartbarkeit + Mobile + Push + Medien + Threads + Reaktionen + +Treffe anschließend eine begründete Architekturentscheidung. +28. Mobile + +Die Plattform soll perspektivisch: + + Desktop + Tablet + Smartphone + +unterstützen. + +Prüfe: + + PWA + responsive Web + React Native + Expo + bestehende Matrix Mobile Clients + +Für MVP möglichst keine unnötige zweite Codebasis erzeugen. +29. Deployment + +Entwickle ein Deployment-Konzept. + +Mindestens: + +Reverse Proxy + │ + ├── VereinsChat Web + │ + ├── VereinsChat API + │ + └── Synapse + │ + └── PostgreSQL + +Prüfe: + + Docker Compose + Produktionsbetrieb + TLS + Domains + Secrets + Backups + Updates + Monitoring + Logging + +30. Beispiel Deployment + +Entwickle beispielsweise: + +chat.verein.de +admin.verein.de +api.verein.de + +oder eine geeignetere Struktur. + +Für lokale Entwicklung: + +docker compose + +mit: + +postgres +synapse +vereinschat-api +vereinschat-web + +31. Observability + +Definiere: + + Health Check + Readiness + Liveness + Application Logs + Error Tracking + Metrics + Synapse Health + Database Health + Sync Status + +Beispielsweise: + +GET /health +GET /ready +GET /metrics + +32. Tests + +Entwickle eine vollständige Teststrategie. + +Mindestens: +Unit Tests + +Für: + + Services + Permissions + Rollen + Sync Logic + Validierung + +Integration Tests + +Für: + + PostgreSQL + Fastify API + Synapse Integration + +E2E Tests + +Für: + +Verein erstellen +↓ +Mitglied erstellen +↓ +Mannschaft erstellen +↓ +Matrix User erstellen +↓ +Matrix Raum erstellen +↓ +Mitglied hinzufügen + +Security Tests + +Für: + + Tenant Isolation + RBAC + IDOR + Authentication + Authorization + +33. Feature-System des Starter-Kits verwenden + +Nutze die bestehende Feature-Struktur des Starter-Kits. + +Erstelle einen Feature-Index: + +features/INDEX.md + +und anschließend einzelne Features. + +Beispiel: + +features/ +├── PROJ-001-project-foundation.md +├── PROJ-002-authentication.md +├── PROJ-003-club-management.md +├── PROJ-004-member-management.md +├── PROJ-005-role-management.md +├── PROJ-006-departments.md +├── PROJ-007-teams.md +├── PROJ-008-seasons.md +├── PROJ-009-invitations.md +├── PROJ-010-synapse-integration.md +├── PROJ-011-matrix-users.md +├── PROJ-012-matrix-rooms.md +├── PROJ-013-membership-sync.md +├── PROJ-014-chat.md +├── PROJ-015-audit-log.md +├── PROJ-016-notifications.md +└── ... + +Jedes Feature muss enthalten: + +Problem +Ziel +Scope +Nicht im Scope +User Stories +Acceptance Criteria +Datenmodell +API +Frontend +Backend +Security +Tests +Dependencies +Definition of Done + +34. Priorisierung + +Ordne Features in: +MVP + +Nur absolut notwendige Funktionen. +V1 + +Wichtige produktionsreife Funktionen. +V2 + +Erweiterungen. +Future + +Optionale Funktionen. + +MVP soll möglichst klein bleiben. +35. MVP + +Das MVP soll mindestens können: + +✓ Login +✓ Verein anlegen +✓ Vereinsadministrator +✓ Mitglieder +✓ Abteilungen +✓ Mannschaften +✓ Rollen +✓ Einladungen +✓ Synapse Verbindung +✓ Matrix User Provisioning +✓ Matrix Räume +✓ automatische Gruppenmitgliedschaft +✓ grundlegender Chat +✓ Audit Log +✓ Tenant Isolation +✓ Docker Deployment +✓ Tests + +36. Nicht im MVP + +Nicht ungefragt riesige Zusatzfeatures bauen. + +Beispielsweise zunächst nicht: + + komplexe Vereinsbuchhaltung + Beitragsverwaltung + Rechnungen + Ticketing + vollständige Vereins-ERP-Funktionen + Turnierverwaltung + Trainingsplanung + KI-Assistent + Videokonferenzen + +Diese können später als Module kommen. +37. Architekturentscheidungen + +Erstelle: + +docs/architecture.md + +Dokumentiere darin alle wichtigen Entscheidungen. + +Beispielsweise: + +ADR-001 +Matrix als Messaging Backend + +ADR-002 +Synapse Admin API nur Backend-seitig + +ADR-003 +PostgreSQL für Vereinsdaten + +ADR-004 +Multi-Tenant Architektur + +ADR-005 +Matrix IDs werden persistent gespeichert + +ADR-006 +Synchronisierung über Jobs + +ADR-007 +Chat Client Strategie + +Jede Entscheidung muss begründet werden. +38. Technische Risiken + +Erstelle: + +docs/risks.md + +Analysiere insbesondere: + + Synapse API Änderungen + Matrix E2EE + Benutzer-Provisionierung + Room Management + Synchronisierung + Tenant Isolation + DSGVO + Minderjährige + Medien-Storage + Push Notifications + Mobile Apps + Migration + +Bewerte: + +Risiko +Wahrscheinlichkeit +Auswirkung +Mitigation + +39. Security Dokumentation + +Erstelle: + +docs/security.md + +Dokumentiere: + + Trust Boundaries + Secrets + Authentication + Authorization + RBAC + Tenant Isolation + Synapse Admin API + API Security + Logging + Datenschutz + Backups + Incident Response + +40. Deployment Dokumentation + +Erstelle: + +docs/deployment.md + +Beschreibe: + + lokale Entwicklung + Docker + Environment Variables + PostgreSQL + Synapse + Reverse Proxy + TLS + Backup + Restore + Updates + Monitoring + +41. Environment Variables + +Definiere ein sauberes Schema. + +Beispielsweise: + +DATABASE_URL= + +SYNAPSE_BASE_URL= +SYNAPSE_ADMIN_TOKEN= + +APP_BASE_URL= +API_BASE_URL= + +SESSION_SECRET= + +SMTP_HOST= +SMTP_PORT= +SMTP_USER= +SMTP_PASSWORD= + +Keine Secrets committen. + +Erstelle: + +.env.example + +42. Migrationen + +Alle Datenbankänderungen müssen über Drizzle Migrationen erfolgen. + +Keine manuellen Änderungen an Produktionsdatenbanken. +43. Codequalität + +Halte dich strikt an: + + TypeScript strict + bestehende Linter + Formatter + bestehende Coding Standards + kleine Module + Dependency Injection, wo sinnvoll + klare Services + keine unnötigen Singletons + keine God Classes + keine Copy/Paste-Architektur + +44. Keine unnötige Komplexität + +YAGNI beachten. + +Nicht jede mögliche Enterprise-Funktion implementieren. + +Die Architektur soll erweiterbar sein, aber der MVP soll überschaubar bleiben. +45. Dokumentationspflicht + +Jede größere technische Entscheidung muss dokumentiert werden. + +Jedes Feature muss nachvollziehbar sein. + +Wenn du eine Entscheidung triffst, dokumentiere: + +Warum? +Welche Alternativen? +Warum verworfen? +Welche Konsequenzen? + +46. Arbeitsweise + +Arbeite iterativ. + +Nach der Analyse: +Schritt 1 + +Repository analysieren. +Schritt 2 + +PRD erstellen. +Schritt 3 + +Architektur erstellen. +Schritt 4 + +Datenmodell erstellen. +Schritt 5 + +Feature Backlog erstellen. +Schritt 6 + +MVP priorisieren. +Schritt 7 + +Foundation implementieren. +Schritt 8 + +Synapse Integration implementieren. +Schritt 9 + +Vereinsverwaltung implementieren. +Schritt 10 + +Chat Integration implementieren. +Schritt 11 + +Tests implementieren. +Schritt 12 + +Deployment implementieren. +47. Autonomie + +Du darfst selbstständig: + + Dateien erstellen + Dateien ändern + Code refactoren + Tests erstellen + Dokumentation erstellen + Migrationen erstellen + Docker-Konfiguration erstellen + +Du darfst jedoch keine bestehende Funktionalität ohne Grund entfernen. + +Bei widersprüchlichen Anforderungen: + + bestehende Projektregeln beachten + Sicherheit priorisieren + Datenintegrität priorisieren + Architektur dokumentieren + sinnvolle Entscheidung treffen + +Nicht bei jeder Kleinigkeit nachfragen. +48. Externe Dokumentation + +Für technische Aussagen über Synapse/Matrix darfst du nicht ausschließlich auf dein vorhandenes Wissen zurückgreifen. + +Nutze die aktuelle offizielle Dokumentation. + +Insbesondere: + + Synapse Admin API + Matrix Client-Server API + Matrix Room API + Matrix Authentication + Matrix E2EE + Synapse Configuration + Synapse Security + +Versionen müssen berücksichtigt werden. +49. Definition of Done + +Ein Feature ist erst fertig, wenn: + +✓ Anforderungen erfüllt +✓ Backend implementiert +✓ Frontend implementiert +✓ Validation vorhanden +✓ Authorization vorhanden +✓ Security geprüft +✓ Tests vorhanden +✓ Migration vorhanden, falls erforderlich +✓ Dokumentation vorhanden +✓ Fehlerbehandlung vorhanden +✓ Logging vorhanden, falls erforderlich +✓ keine TypeScript-Fehler +✓ Linter erfolgreich +✓ Tests erfolgreich + +50. Erste Aufgabe + +Beginne NICHT mit dem Coding. + +Führe zunächst eine vollständige Analyse des vorhandenen Repositories durch. + +Erstelle danach: + +docs/PRD.md +docs/architecture.md +docs/security.md +docs/deployment.md +docs/risks.md +features/INDEX.md + +Erstelle außerdem das initiale Feature-Backlog. + +Gib nach der Analyse einen Bericht aus mit: + + Bestehende Architektur + Wiederverwendbare Komponenten + Fehlende Komponenten + Empfohlene Zielarchitektur + Datenmodell + Synapse-Integrationsstrategie + Sicherheitsmodell + Multi-Tenant-Modell + MVP + V1 + V2 + Risiken + Feature-Backlog + Implementierungsreihenfolge + +Erst wenn diese Grundlage erstellt wurde, beginne mit der Implementierung. +51. Wichtig: Produktqualität + +VereinsChat soll kein Demo-Projekt werden. + +Behandle das Projekt als reale produktive SaaS-/Self-Hosted-Anwendung. + +Code muss: + + wartbar + testbar + sicher + nachvollziehbar + erweiterbar + dokumentiert + +sein. + +Die Plattform soll später von vielen Vereinen eingesetzt werden können. +52. Zielbild + +Am Ende soll ein Vereinsadministrator eine Plattform sehen, die ungefähr folgendes ermöglicht: + +VereinsChat + +SV Musterstadt + +Dashboard +│ +├── Mitglieder +│ +├── Abteilungen +│ ├── Fußball +│ ├── Handball +│ └── Volleyball +│ +├── Mannschaften +│ ├── 1. Herren +│ ├── 2. Herren +│ └── A-Jugend +│ +├── Chatgruppen +│ ├── Verein +│ ├── Vorstand +│ ├── Fußball +│ └── 1. Herren +│ +├── Einladungen +│ +├── Rollen & Rechte +│ +├── Aktivitäten +│ +└── Einstellungen + +Der normale Benutzer bekommt dagegen eine einfache Kommunikationsoberfläche: + +VereinsChat + +Chats + +📢 Verein +⚽ Fußball +⚽ 1. Herren +👥 Trainer +💬 Max Müller + + Nachrichten + Bilder + Dateien + Reaktionen + +Die Komplexität von Synapse/Matrix bleibt im Hintergrund. +53. Endziel + +Baue keine reine Admin-Oberfläche für Synapse. + +Baue eine: +Vereinsplattform mit integrierter Matrix-Kommunikation. + +Synapse ist die technische Messaging-Infrastruktur. + +VereinsChat ist das eigentliche Produkt. + +Die wichtigste Abstraktion lautet: + +Verein + ↓ +Abteilung + ↓ +Mannschaft + ↓ +Mitglied + ↓ +Kommunikationsgruppe + ↓ +Matrix Room + +Diese Abstraktion muss sich durch die gesamte Architektur ziehen. + +Beginne jetzt mit der Repository-Analyse und erstelle anschließend die oben definierten Dokumente und das Feature-Backlog. + +Damit hast du einen Prompt, der den Agenten nicht nur zum Programmieren, sondern zum kompletten Ausarbeiten des Produkts zwingt. Besonders wichtig finde ich dabei die Trennung „VereinsChat verwaltet den Verein / Synapse verwaltet die Kommunikation“. Dadurch bleibt die Architektur sauber und wir können später sogar verschiedene Matrix/Synapse-Deployments dahinter betreiben. diff --git a/docs/PRD.md b/docs/PRD.md index 1eedb16..948e841 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1,33 +1,58 @@ # Product Requirements Document -> Filled out by `/requirements` in Init Mode, or manually before the first `/requirements` run. Keep every section concrete enough that a stranger could read it and know what NOT to build. +> TalkTime. Details siehe [docs/vereinschat/PRD.md](vereinschat/PRD.md) und [docs/vereinschat/architecture.md](vereinschat/architecture.md). ## Vision -_2-3 sentences: what is this product, who is it for, why does it need to exist. Not a feature list — the problem it solves._ +TalkTime ist eine selbst hostbare, DSGVO-freundliche Kommunikations- und Verwaltungsplattform für Sportvereine — Ersatz für WhatsApp-Gruppen. Matrix/Synapse ist das Messaging-Backend im Hintergrund; der Verein verwaltet stattdessen Abteilungen, Mannschaften, Mitglieder und Rollen, Kommunikationsgruppen entstehen automatisch aus dieser Struktur. ## Target Users -_Name the actual user type(s), not "everyone". For each: their concrete pain point today, and what they're using instead of this product._ +- **Vereinsadministrator (CLUB_ADMIN):** verwaltet heute Mitglieder/Gruppen manuell über WhatsApp und Excel-Listen, kein Datenschutzkonzept. +- **Abteilungs-/Mannschaftsleiter, Trainer, Co-Trainer, Betreuer:** brauchen Kommunikation mit ihrer Gruppe, ohne selbst Matrix/IT verstehen zu müssen. +- **Mitglied/Spieler, Eltern (bei Minderjährigen):** wollen einfache Chat-Teilnahme ohne Rufnummer-Preisgabe an einen US-Messenger. +- **Systemadministrator (SYSTEM_ADMIN):** betreibt die Plattform für mehrere Vereine (Multi-Tenant). ## Core Features (Roadmap) | Priority | Feature | Status | |----------|---------|--------| -| P0 (MVP) | _Feature 1_ | Planned | -| P0 (MVP) | _Feature 2_ | Planned | -| P1 | _Feature 3_ | Planned | -| P2 | _Feature 4_ | Planned | +| P0 (MVP) | Auth (Login/Logout/Session, Passwort-Reset) | Planned | +| P0 (MVP) | Verein anlegen + Vereinsadministrator | Planned | +| P0 (MVP) | Mitgliederverwaltung | Planned | +| P0 (MVP) | Abteilungsverwaltung | Planned | +| P0 (MVP) | Mannschaftsverwaltung (inkl. Saison) | Planned | +| P0 (MVP) | Rollen & Berechtigungen (RBAC) | Planned | +| P0 (MVP) | Einladungssystem | Planned | +| P0 (MVP) | Synapse-Integration (SynapseService, Health Check) | Planned | +| P0 (MVP) | Matrix User Provisioning | Planned | +| P0 (MVP) | Matrix-Raum-Verwaltung + automatische Gruppenzuordnung | Planned | +| P0 (MVP) | Membership-Sync (Outbox, Retry, Reconciliation) | Planned | +| P0 (MVP) | Grundlegender Chat (eigene Oberfläche via Matrix Client-Server API) | Planned | +| P0 (MVP) | Audit Log | Planned | +| P0 (MVP) | Tenant Isolation (Multi-Club) | Planned | +| P1 | CLUB_MANAGER-Rolle, PARENT-Rolle vollständig | Planned | +| P1 | ASSISTANT_COACH / SUPERVISOR als eigene Rollen | Planned | +| P1 | E-Mail-Verification vollständig, manueller Resync in UI | Planned | +| P2 | OIDC-Login, Push-Notifications, PWA | Planned | +| P2 | Medien-Retention-Policies, erweiterte Benachrichtigungen | Planned | -P0 = cannot ship without it. P1 = next after MVP validates. P2 = explicitly deferred, not "maybe never." +P0 = MVP. Details/Begründung siehe [docs/vereinschat/PRD.md](vereinschat/PRD.md) Abschnitt 5-6. ## Success Metrics -_Concrete, measurable, with a number or threshold — not "users are happy". E.g. "50 signups in first month", "task completion rate > 80%"._ +- Erstverein produktiv (Login bis erste Matrix-Nachricht) in unter 1 Tag Einrichtungszeit. +- 0 Tenant-Isolation-Vorfälle (kein Cross-Club-Datenzugriff) in QA/Security-Tests. +- Sync-Konsistenz: Reconciliation-Job meldet < 1% Abweichung TalkTime vs. Synapse im Dauerbetrieb. ## Constraints -_Real limits that shape decisions: timeline, budget, team size, must-run-on-existing-server, no third-party SaaS dependencies, etc._ +- Muss auf bestehendem Self-Hosted-Server laufen (Starter-Kit-Deployment: git pull + rsync + systemd, kein Vendor-Lock-in). +- Synapse-Instanz wird separat betrieben/bereitgestellt (Deployment-Doku offen, siehe PROJ-Feature Synapse-Integration). +- Kein Speichern von Nachrichteninhalten in der TalkTime-eigenen PostgreSQL-DB — nur Zuordnungsdaten. +- Synapse Admin Token darf Backend nie verlassen. ## Non-Goals -_Explicitly out of scope for this version — the things people will ask for that you're deliberately not building yet, and why._ +- Vereinsbuchhaltung, Beitragsverwaltung, Rechnungen, Ticketing, Vereins-ERP — separates Modul, nicht MVP/V1. +- Turnierverwaltung, Trainingsplanung, KI-Assistent, Videokonferenzen — nicht in absehbarer Roadmap. +- Eigene Mobile-App (React Native) — MVP ist responsive Web/PWA-fähig, keine zweite Codebasis. --- -Use `/requirements` to create detailed feature specifications for each item in the roadmap above. +Feature-Specs siehe `features/INDEX.md`. diff --git a/docs/vereinschat/PRD.md b/docs/vereinschat/PRD.md new file mode 100644 index 0000000..bcb6c71 --- /dev/null +++ b/docs/vereinschat/PRD.md @@ -0,0 +1,109 @@ +# PRD: TalkTime + +> Status: Entwurf. Starter-Kit (`ai-coding-starter-kit-v2`) noch nicht im Repo vorhanden — Annahmen zu Stack/Struktur unten explizit markiert. Vor Implementierung: Repo klonen und Kapitel "Offene Annahmen" verifizieren. + +## 0. Generalisierung: nicht nur Sportvereine + +Datenmodell und Code sind bewusst generisch gehalten (`organizations` statt `clubs`, Rollen wie `ORG_ADMIN`/`TEAM_LEAD`/`GUARDIAN` statt sportspezifischer Begriffe). Die Kernabstraktion `Organisation → Abteilung → Team → Mitglied → Kommunikationsgruppe → Matrix-Room` passt auf mehrere Zielgruppen: + +| Konzept (Code) | Sportverein (Leitbeispiel) | Firma | NGO / Verband | +|---|---|---|---| +| Organization | Verein (SV Musterstadt) | Firma (Muster GmbH) | NGO/Dachverband | +| Department | Abteilung (Fußball, Handball) | Abteilung (Vertrieb, IT) | Bereich (Katastrophenhilfe, Bildung) | +| Team | Mannschaft (1. Herren) | Team/Projektgruppe | Projektgruppe/Einsatzteam | +| ORG_ADMIN | Vereinsadministrator | Geschäftsführung/HR-Admin | Vorstand/Geschäftsstelle | +| TEAM_MANAGER | Mannschaftsleiter | Teamlead | Projektleitung | +| TEAM_LEAD | Trainer | Fachlicher Lead | Einsatzleiter | +| SUPERVISOR | Betreuer | Mentor/Buddy | Betreuer:in | +| GUARDIAN | Eltern (bei Minderjährigen) | — (meist nicht relevant) | Erziehungsberechtigte (bei Jugendarbeit) | +| Chatgruppe/Matrix-Room | Mannschafts-/Abteilungschat | Team-Channel | Projekt-/Einsatzchat | + +Die Sportverein-Beispiele (Abschnitte 2-4 unten) bleiben als durchgängiges Leitbeispiel stehen, weil sie die konkreteste/komplexeste Hierarchie abdecken (mehrere Mannschaften, Minderjährige, Saisons). Für Firma/NGO gilt dieselbe Struktur mit den Begriffen aus der Tabelle. + +## 1. Produkt + +**Produktname:** TalkTime + +**Vision:** Datenschutzfreundliche, selbst hostbare Kommunikations- und Verwaltungsplattform für Sportvereine, die WhatsApp als Vereins-Kommunikationskanal ersetzt. + +**Zielgruppe:** Sportvereine (Amateur-/Breitensport), Abteilungen, Mannschaften, Trainer, Eltern, Mitglieder — primär im deutschsprachigen Raum, DSGVO-relevant. + +**Problem:** +- WhatsApp-Gruppen für Vereine sind datenschutzrechtlich problematisch (Kontaktabgleich, US-Anbieter, keine Vereinsstruktur-Abbildung). +- Klassische Vereinssoftware (z. B. Mitgliederverwaltung) bietet keine Echtzeit-Kommunikation. +- Kein Tool bildet Vereinsstruktur (Abteilung → Mannschaft → Mitglied) 1:1 auf Kommunikationsgruppen ab. + +**Lösung:** Eigene Verwaltungsschicht über Matrix/Synapse. Verein denkt in Vereins-Objekten (Mitglied, Mannschaft, Rolle), Matrix bleibt unsichtbare Infrastruktur im Hintergrund. + +**USP:** +- Automatische Gruppenzuordnung basierend auf Vereinsstruktur (kein manuelles Gruppenmanagement). +- Selbst hostbar, DSGVO-konform, keine Datenweitergabe an Dritte. +- Rollenmodell exakt auf Vereinsrealität zugeschnitten (Trainer, Betreuer, Abteilungsleiter, ...). + +**Abgrenzung zu WhatsApp:** kein Kontaktabgleich mit Telefonbuch, keine Rufnummer als Identität nötig, Admin-gesteuerte Gruppenmitgliedschaft statt manuellem Hinzufügen, Datenhoheit beim Verein. + +**Abgrenzung zu klassischen Vereinssoftwares (z. B. Vereinsverwaltungen mit Mitgliederdatenbank):** Fokus liegt auf Kommunikation in Echtzeit, nicht auf Beitragsverwaltung/Buchhaltung (bewusst nicht im MVP, siehe Abschnitt 5). + +**Zielmärkte:** MVP für einzelne Sportvereine, Skalierung Richtung Verbände/Dachorganisationen (Multi-Tenant) als spätere Erweiterung. + +## 2. Benutzerrollen + +| Rolle | Beschreibung | Benötigt für MVP | +|---|---|---| +| SYSTEM_ADMIN | Betreiber der Plattform, verwaltet Vereine/Tenants, keine Vereinsdaten-Einsicht per Default | Ja | +| CLUB_ADMIN (Vereinsadministrator) | Voller Zugriff auf einen Verein, verwaltet Abteilungen/Mannschaften/Rollen/Mitglieder | Ja | +| CLUB_MANAGER (Vereinsmanager) | Wie CLUB_ADMIN, ohne Rechte-/Rollenvergabe an andere Admins (Privilege-Escalation-Schutz) | V1 | +| DEPARTMENT_ADMIN (Abteilungsleiter) | Verwaltet eine Abteilung inkl. deren Mannschaften | Ja | +| TEAM_MANAGER (Mannschaftsleiter) | Verwaltet eine Mannschaft (Mitglieder, Trainer zuweisen) | Ja | +| COACH (Trainer) | Kommunikation mit eigener Mannschaft, kein Verwaltungszugriff | Ja | +| ASSISTANT_COACH (Co-Trainer) | Wie Trainer, eingeschränkter | V1 | +| SUPERVISOR (Betreuer) | Begleitfunktion, Zugriff wie Co-Trainer auf zugewiesene Mannschaft | V1 | +| MEMBER (Mitglied/Spieler) | Teilnahme an zugewiesenen Gruppen, kein Verwaltungszugriff | Ja | +| PARENT (Eltern/Erziehungsberechtigte) | Stellvertretend für minderjährige Mitglieder, eingeschränkte Sicht | V1 (MVP: rudimentär) | + +Rollen sind pro Verein und ggf. pro Abteilung/Mannschaft skopiert (nicht global außer SYSTEM_ADMIN/CLUB_ADMIN). + +## 3. Vereinsstruktur + +Hierarchie: `Verein → Abteilung → Mannschaft → Mitgliedschaft (Saison-gebunden)`. + +- **Verein**: Tenant-Wurzel, hat Name, Domain/Slug, Einstellungen, Matrix-Homeserver-Zuordnung. +- **Abteilung**: gehört zu genau einem Verein (z. B. Fußball, Handball). +- **Mannschaft**: gehört zu genau einer Abteilung, gehört zu einer Saison. +- **Saison**: zeitlich begrenzt (z. B. 2025/2026), Mannschaften/Mitgliedschaften sind saisongebunden für Wiedereintritt/Historie. +- **Mitgliedschaft**: verknüpft Mitglied mit Verein (immer), optional mit Mannschaft(en) und Rolle(n). Ein Mitglied kann mehrere Mannschaften und mehrere Rollen gleichzeitig haben (z. B. Spieler in Mannschaft A, Co-Trainer in Mannschaft B). +- **Vorstand**: als spezielle "Abteilung ohne Mannschaften" oder eigener Objekttyp — Empfehlung: als Abteilung ohne Mannschaftsebene modellieren, um Datenmodell nicht zu verzweigen (siehe architecture.md ADR). + +## 4. Mitgliederverwaltung + +Attribute: Vorname, Nachname, E-Mail, Telefon (optional), Geburtsdatum, Status (aktiv/inaktiv/gesperrt/ausgetreten), Matrix User ID. + +Regeln: +- **Minderjährige**: Geburtsdatum bestimmt `is_minor`. Für Minderjährige ist mind. ein Elternteil-Konto (PARENT) verknüpfbar; Einladungs-/Consent-Flow für Erziehungsberechtigte im V1. +- **Mehrere Mannschaften/Rollen**: über separate Zuordnungstabellen (n:m), nicht über Felder am Mitglied. +- **Vereinswechsel**: Mitglied-Datensatz bleibt beim ursprünglichen Verein (Tenant-Grenze); Wechsel = Austritt + Neuanlage im Zielverein. Kein Cross-Tenant-Merge im MVP. +- **Austritt**: Status `left`, Matrix-Raum-Mitgliedschaften werden entfernt, Matrix-Account wird deaktiviert (nicht gelöscht), Daten nach Aufbewahrungsfrist anonymisiert/gelöscht (siehe security.md/DSGVO). +- **Sperrung**: Status `suspended`, Login gesperrt, Matrix-Account deaktiviert, Daten bleiben (Reaktivierung möglich). +- **Löschung**: harte Löschung nur auf expliziten Antrag (DSGVO-Auskunft/Löschbegehren) unter Beachtung gesetzlicher Aufbewahrungsfristen; Standardfall ist Anonymisierung. +- **Wiedereintritt**: neue Mitgliedschaft/Saison-Zuordnung, alte Matrix-ID wird nach Möglichkeit wiederverwendet (siehe Abschnitt Matrix User Provisioning), Historie bleibt nachvollziehbar. + +## 5. MVP-Scope + +**MVP:** Login, Verein anlegen, Vereinsadministrator, Mitglieder, Abteilungen, Mannschaften, Rollen (Kernrollen), Einladungen, Synapse-Verbindung, Matrix User Provisioning, Matrix-Räume, automatische Gruppenmitgliedschaft, grundlegender Chat (eingebetteter/verlinkter Matrix-Client), Audit Log, Tenant Isolation, Docker Deployment, Tests. + +**Nicht im MVP:** Vereinsbuchhaltung, Beitragsverwaltung, Rechnungen, Ticketing, Vereins-ERP, Turnierverwaltung, Trainingsplanung, KI-Assistent, Videokonferenzen, PARENT-Rolle voll ausgebaut, ASSISTANT_COACH/SUPERVISOR als eigene Rolle (MVP: unter COACH/MEMBER subsumiert oder als V1 nachgezogen — siehe features/INDEX.md). + +## 6. Priorisierung + +- **MVP**: siehe oben. +- **V1**: PARENT-Rolle vollständig, ASSISTANT_COACH/SUPERVISOR getrennt, CLUB_MANAGER, Passwort-Reset/E-Mail-Verification vollständig, Sync-Resync-UI, erweiterte Audit-Log-Ansicht. +- **V2**: OIDC-Login, Push-Notifications, Medien-Retention-Policies, erweiterte Benachrichtigungssteuerung, PWA. +- **Future**: React-Native-App, Videokonferenzen-Integration, Vereins-Module (Beitragsverwaltung etc.) als Plugins. + +## 7. Offene Annahmen (bis Starter-Kit-Analyse nachgeholt) + +- Starter-Kit-Stack wie im Master-Prompt beschrieben: React+TS+Vite+Tailwind+shadcn/ui (Frontend), Fastify+TypeScript (Backend), Drizzle ORM, PostgreSQL. **Nicht verifiziert** — Repo lag beim Erstellen dieses Dokuments nicht lokal vor. +- Vorhandene Auth-Lösung des Starter-Kits unbekannt — Abschnitt 17 im Master-Prompt ("Untersuche zuerst die Authentifizierung") noch offen. +- Vorhandene Feature-/Skill-/Agent-Struktur des Starter-Kits unbekannt. + +**Nächster Schritt:** Repository klonen, gegen dieses PRD abgleichen, Annahmen bestätigen oder korrigieren, dann architecture.md/Datenmodell verfeinern. diff --git a/docs/vereinschat/architecture.md b/docs/vereinschat/architecture.md new file mode 100644 index 0000000..3dd4c7d --- /dev/null +++ b/docs/vereinschat/architecture.md @@ -0,0 +1,145 @@ +# Architektur: TalkTime + +> Voraussetzung: Starter-Kit noch nicht lokal analysiert. Diese Architektur ist die Zielarchitektur gemäß Master-Prompt; Abgleich mit tatsächlichem Starter-Kit-Code steht aus. + +## 1. Grundarchitektur + +``` + ┌─────────────────────────┐ + │ TalkTime │ + │ Web Frontend │ + │ React + TypeScript │ + └────────────┬─────────────┘ + │ HTTPS + ▼ + ┌─────────────────────────┐ + │ TalkTime API │ + │ Fastify + TypeScript │ + │ Auth / Vereine / │ + │ Mitglieder / Mannsch. │ + │ Rollen / Gruppen / │ + │ Invitations / Synapse │ + └──────┬───────────┬───────┘ + ▼ ▼ + PostgreSQL Synapse + │ + ▼ + Matrix +``` + +Prinzip: PostgreSQL hält Vereins-/Verwaltungsdaten und die Zuordnung Vereinsobjekt ↔ Matrix-Objekt. Nachrichteninhalte liegen ausschließlich in Synapse. + +## 2. Kernabstraktion + +``` +Verein → Abteilung → Mannschaft → Mitglied → Kommunikationsgruppe → Matrix Room +``` + +Diese Kette zieht sich durch Datenmodell, API, Frontend und Sync-Logik. + +## 3. SynapseService (Adapter-Schicht) + +Browser darf niemals direkt mit Synapse Admin API sprechen: + +``` +Browser → TalkTime Backend → Synapse Admin API +``` + +`SynapseService` kapselt: +- Benutzer anlegen/suchen/aktivieren/deaktivieren/abrufen +- Räume erstellen/verwalten +- Mitglieder zu Räumen hinzufügen/entfernen +- Health Check + +Admin-Token verlässt das Backend nie (nicht in Frontend, Logs, API-Responses, Git). + +## 4. Matrix User Provisioning + +``` +Vereinsmitglied → TalkTime Account → Matrix Account +``` + +Matrix-ID-Schema: `@vorname.nachname:chat.vereinschat.de` (Domain pro Deployment konfigurierbar). + +Regeln: +- Namenskonflikt: Suffix mit fortlaufender Nummer (`@max.mustermann2:...`), Zuordnung bleibt stabil über Lebenszeit des Mitglieds. +- Matrix-ID wird nach Erstellung nicht mehr geändert, auch nicht bei Namensänderung (Anzeige-Name in Matrix wird aktualisiert, ID bleibt). +- E-Mail-Änderung: aktualisiert Matrix-Account-Metadaten, nicht die ID. +- Deaktivierung/Reaktivierung: über Synapse Admin API (`deactivate`/`reactivate`), Datensatz in `synapse_users` bleibt für Referenzintegrität. +- Löschung: Deaktivierung + DSGVO-konforme Datenminimierung, keine harte Matrix-Account-Löschung (Synapse unterstützt das ohnehin nur eingeschränkt), stattdessen Anonymisierung der Metadaten. + +## 5. Chatgruppen / Räume + +Automatische Raumerstellung: 1 Mannschaft → 1 Matrix-Raum (Standard). Zusätzlich Abteilungs- und Vereinsräume optional/automatisch je nach Konfiguration. + +``` +Mannschaft erstellt → Matrix Raum erstellen → Trainer hinzufügen → Mitglieder hinzufügen +``` + +Entfernen: + +``` +Mitglied aus Mannschaft entfernt → TalkTime aktualisieren → Matrix Membership aktualisieren (async Job) +``` + +## 6. Synchronisierung + +Asynchrone Sync-Jobs statt synchroner Matrix-Calls im Request-Pfad, um UI nicht zu blockieren und Teilausfälle zu isolieren. + +- **Outbox Pattern**: Jede Vereinsdaten-Änderung, die Matrix-Seiteneffekte hat, erzeugt einen Eintrag in `sync_jobs` (Typ, Payload, Status) in derselben DB-Transaktion wie die fachliche Änderung. +- **Worker** verarbeitet `sync_jobs` idempotent, mit Retry (exponential backoff) und Statusfeldern (`pending`, `processing`, `failed`, `done`). +- **Reconciliation**: periodischer Abgleich TalkTime-Soll-Zustand vs. Synapse-Ist-Zustand (Health-Check-Job), meldet Abweichungen ins Audit Log, erlaubt manuellen Resync über Admin-UI. +- Fachliche Operation (z. B. Mannschaft erstellen) schlägt NICHT komplett fehl, wenn Matrix-Call fehlschlägt — Datensatz wird angelegt, Sync-Job bleibt `pending`/`failed` und wird retried bzw. manuell resynct. + +## 7. RBAC + +Rollenhierarchie im Code (generisch, siehe PRD Abschnitt 0 für die Sportverein/Firma/NGO-Begriffszuordnung): `SYSTEM_ADMIN > ORG_ADMIN > ORG_MANAGER > DEPARTMENT_ADMIN > TEAM_MANAGER > TEAM_LEAD/ASSISTANT_LEAD/SUPERVISOR > MEMBER/GUARDIAN`. + +Prinzipien: +- Rollenvergabe nur durch Rolle mit strikt höherem Rang im selben oder übergeordneten Scope. +- Ein Nutzer kann sich selbst keine höherwertige Rolle zuweisen (serverseitige Prüfung, nicht nur UI). +- Rollen sind scope-gebunden (Verein/Abteilung/Mannschaft) — Prüfung immer `hasRole(user, role, scopeId)`, nie global außer SYSTEM_ADMIN. + +## 8. Multi-Tenant + +Jede Tabelle mit Organisationsbezug trägt `organization_id` (im Code weiterhin `organizationId`, Tabellenname `organizations` — generisch statt `clubs`, siehe PRD Abschnitt 0). Jede Query wird durch eine zentrale Query-Scoping-Schicht (z. B. Repository-Methode erzwingt `WHERE organization_id = :currentOrganizationId`) gefiltert — nie durch reine Anwendungslogik im Controller. API-Guards prüfen `organization_id` aus Session/Token gegen angefragte Ressource (IDOR-Schutz). SYSTEM_ADMIN-Zugriff über explizite, auditierte Ausnahme-Rolle, nicht Standardpfad. + +## 9. Chat-Client-Strategie + +Empfehlung (vorläufig, bis UX-Validierung): eigene, schlanke Chat-Oberfläche im TalkTime-Frontend über Matrix Client-Server API (z. B. matrix-js-sdk), kein vollständiger Matrix-Client eingebettet. + +Begründung: +- Eingebetteter Client (z. B. Element Web via iframe) ist schnell integriert, aber UX wirkt "fremd" und schwer an Vereinsstruktur/Terminologie anzupassen. +- Eigene Oberfläche mit SDK erlaubt, nur Verein-relevante Räume zu zeigen, Vereins-Terminologie zu verwenden, und Matrix komplett zu verstecken (siehe Produktvision). +- Nachteil: höherer Aufwand, E2EE/Threads/Reaktionen müssen selbst integriert werden — für MVP bewusst nur Basis-Messaging + Bilder/Dateien, Threads/Reaktionen als V1/V2. +- Alternative "Kombination" (Reactions/Threads via SDK, Media via Matrix Content Repo direkt) für V1 vorgesehen. + +Detaillierte Abwägung siehe ADR-007. + +## 10. ADRs + +**ADR-001 — Matrix als Messaging-Backend** +Begründung: föderationsfähig, offener Standard, self-hostbar, aktive Entwicklung, E2EE-Unterstützung vorhanden. Alternative (eigenes Chat-Backend) verworfen wegen Aufwand für E2EE/Föderation/Mobile-Clients. + +**ADR-002 — Synapse Admin API nur Backend-seitig** +Begründung: Admin-Token darf nie ins Frontend. Alternative (Client ruft Synapse direkt mit User-Token) verworfen, da Vereinslogik (RBAC, Tenant-Scoping) dann nicht zentral durchsetzbar wäre. + +**ADR-003 — PostgreSQL für Vereinsdaten** +Begründung: passt zu Starter-Kit (Drizzle ORM), relationales Modell passt zur hierarchischen Vereinsstruktur. Keine Duplikation von Matrix-Nachrichteninhalten in Postgres. + +**ADR-004 — Multi-Tenant-Architektur (Shared DB, `organization_id`-Spalte)** +Begründung: einfacher zu betreiben als DB-pro-Tenant bei erwarteter Vereinsgröße (Hunderte, nicht Zehntausende Mitglieder pro Verein). Alternative (Schema-pro-Tenant) verworfen wegen Migrationsaufwand bei vielen Vereinen. + +**ADR-005 — Matrix-IDs werden persistent gespeichert und nicht geändert** +Begründung: Matrix-ID-Änderung ist in Matrix nicht nativ vorgesehen (faktisch neuer Account) und würde Raumhistorie/Mitgliedschaften zerreißen. + +**ADR-006 — Synchronisierung über asynchrone Jobs (Outbox Pattern)** +Begründung: entkoppelt Vereinsverwaltung von Synapse-Verfügbarkeit, verhindert inkonsistente Teiltransaktionen zwischen zwei Systemen ohne verteilte Transaktion. + +**ADR-007 — Chat-Client: eigene Oberfläche mit Matrix Client-Server API** +Begründung: siehe Abschnitt 9. Re-evaluieren nach MVP-Nutzerfeedback, insbesondere E2EE-Aufwand. + +## 11. Offene Punkte + +- Exakte Synapse-Version und API-Kompatibilität noch zu klären (Referenz: aktuelle offizielle Synapse-/Matrix-Doku zum Zeitpunkt der Implementierung). +- Starter-Kit-Auth-Mechanismus (Session vs. JWT) noch unbekannt — beeinflusst Abschnitt "Authentifizierung" in PRD. diff --git a/frontend/DEVLOG.md b/frontend/DEVLOG.md new file mode 100644 index 0000000..3f0076e --- /dev/null +++ b/frontend/DEVLOG.md @@ -0,0 +1,167 @@ +# frontend – Dev Log + +## 2026-09-11 13:45 – 13:45 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** vsb-talk + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 13:47 – 13:47 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 13:52 – 13:53 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** backend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 13:53 – 13:54 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 13:55 – 13:56 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 13:56 – 13:57 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 13:59 – 14:02 (2m) +**Beschreibung:** Claude Code Session +**Projekt:** backend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 14:13 – 14:14 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 14:19 – 14:23 (4m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 14:38 – 14:40 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** backend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 14:41 – 14:43 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 14:52 – 14:54 (2m) +**Beschreibung:** Claude Code Session +**Projekt:** backend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 14:55 – 14:55 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 14:56 – 14:56 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- +## 2026-09-11 14:58 – 14:58 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 2 +- + +--- diff --git a/frontend/index.html b/frontend/index.html index efc6c5b..b5d9ebe 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - AI Coding Starter Kit + TalkTime
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ea752e6..daf90e2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -899,6 +899,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "9.39.5", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", @@ -2886,16 +2910,17 @@ } }, "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "devOptional": true, + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -3536,6 +3561,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -3641,7 +3690,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -3651,6 +3700,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "optional": true, + "peer": true + }, "node_modules/fastq": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", @@ -3999,11 +4066,12 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "devOptional": true, - "license": "MIT" + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -4540,7 +4608,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4741,6 +4809,17 @@ "node": ">=8.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -5201,7 +5280,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" diff --git a/frontend/package.json b/frontend/package.json index e09de5b..27a9b67 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "ai-coding-starter-kit-frontend", + "name": "talktime-frontend", "version": "1.0.0", "private": true, "type": "module", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2dc103d..d2c9894 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,13 +1,33 @@ +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { Toaster } from "@/components/ui/sonner"; +import { AuthProvider } from "@/lib/auth-context"; +import { OrganizationProvider } from "@/lib/organization-context"; +import { ProtectedRoute } from "@/routes/ProtectedRoute"; +import { LoginPage } from "@/pages/LoginPage"; +import { RegisterPage } from "@/pages/RegisterPage"; +import { DashboardPage } from "@/pages/DashboardPage"; +import { MembersPage } from "@/pages/MembersPage"; +import { AcceptInvitationPage } from "@/pages/AcceptInvitationPage"; function App() { return ( - <> -
-

AI Coding Starter Kit

-
- - + + + + + } /> + } /> + } /> + }> + } /> + } /> + + } /> + + + + + ); } diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..2cfcd58 --- /dev/null +++ b/frontend/src/components/auth/LoginForm.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useNavigate } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useAuth } from "@/lib/auth-context"; + +const loginSchema = z.object({ + email: z.string().email("Bitte gültige E-Mail eingeben"), + password: z.string().min(8, "Mindestens 8 Zeichen"), +}); + +type LoginValues = z.infer; + +export function LoginForm() { + const { login } = useAuth(); + const navigate = useNavigate(); + const [formError, setFormError] = useState(null); + + const form = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { email: "", password: "" }, + }); + + async function onSubmit(values: LoginValues) { + setFormError(null); + try { + await login(values.email, values.password); + navigate("/"); + } catch (err) { + setFormError((err as Error).message); + } finally { + form.reset(undefined, { keepValues: true }); + } + } + + return ( + + + TalkTime Login + + +
+ + ( + + E-Mail + + + + + + )} + /> + ( + + Passwort + + + + + + )} + /> + {formError &&

{formError}

} + + + +

+ Noch kein Account?{" "} + + Registrieren + +

+
+
+ ); +} diff --git a/frontend/src/components/auth/RegisterForm.tsx b/frontend/src/components/auth/RegisterForm.tsx new file mode 100644 index 0000000..d4ae797 --- /dev/null +++ b/frontend/src/components/auth/RegisterForm.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useNavigate } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useAuth } from "@/lib/auth-context"; + +const registerSchema = z.object({ + email: z.string().email("Bitte gültige E-Mail eingeben"), + password: z.string().min(8, "Mindestens 8 Zeichen"), +}); + +type RegisterValues = z.infer; + +export function RegisterForm() { + const { setToken } = useAuth(); + const navigate = useNavigate(); + const [error, setError] = useState(null); + + const form = useForm({ + resolver: zodResolver(registerSchema), + defaultValues: { email: "", password: "" }, + }); + + async function onSubmit(values: RegisterValues) { + setError(null); + try { + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + throw new Error(body.error ?? `Fehler (${res.status})`); + } + const { token } = (await res.json()) as { token: string }; + await setToken(token); + navigate("/"); + } catch (err) { + setError((err as Error).message); + } + } + + return ( + + + Account erstellen + + +
+ + ( + + E-Mail + + + + + + )} + /> + ( + + Passwort + + + + + + )} + /> + {error &&

{error}

} + + + +
+
+ ); +} diff --git a/frontend/src/components/dashboard/CreateOrganizationForm.tsx b/frontend/src/components/dashboard/CreateOrganizationForm.tsx new file mode 100644 index 0000000..b0fb456 --- /dev/null +++ b/frontend/src/components/dashboard/CreateOrganizationForm.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useAuth } from "@/lib/auth-context"; +import { useOrganization } from "@/lib/organization-context"; + +const createOrganizationSchema = z.object({ + name: z.string().min(2, "Mindestens 2 Zeichen"), + slug: z + .string() + .min(2, "Mindestens 2 Zeichen") + .regex(/^[a-z0-9-]+$/, "Nur Kleinbuchstaben, Ziffern und Bindestriche"), +}); + +type CreateOrganizationValues = z.infer; + +export function CreateOrganizationForm() { + const { token } = useAuth(); + const { refresh } = useOrganization(); + const [error, setError] = useState(null); + + const form = useForm({ + resolver: zodResolver(createOrganizationSchema), + defaultValues: { name: "", slug: "" }, + }); + + async function onSubmit(values: CreateOrganizationValues) { + setError(null); + try { + const res = await fetch("/api/organizations", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(values), + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + throw new Error(body.error ?? `Fehler (${res.status})`); + } + form.reset(); + await refresh(); + } catch (err) { + setError((err as Error).message); + } + } + + return ( + + + Verein anlegen + + +
+ + ( + + Name + + + + + + )} + /> + ( + + Kurzname (URL) + + + + + + )} + /> + {error &&

{error}

} + + + +
+
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardTiles.tsx b/frontend/src/components/dashboard/DashboardTiles.tsx new file mode 100644 index 0000000..8484794 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardTiles.tsx @@ -0,0 +1,76 @@ +import { Link } from "react-router-dom"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Users, + Layers, + Shield, + MessageSquare, + Mail, + type LucideIcon, +} from "lucide-react"; +import { + useDashboardSummary, + type DashboardSummary, +} from "@/hooks/use-dashboard-summary"; + +interface TileDef { + key: keyof DashboardSummary; + label: string; + icon: LucideIcon; + href?: string; +} + +const tileDefs: TileDef[] = [ + { key: "members", label: "Mitglieder", icon: Users, href: "/members" }, + { key: "departments", label: "Abteilungen", icon: Layers }, + { key: "teams", label: "Mannschaften", icon: Shield }, + { key: "chatRooms", label: "Chatgruppen", icon: MessageSquare }, + { key: "invitations", label: "Einladungen", icon: Mail }, +]; + +interface DashboardTilesProps { + token: string | null; + organizationId: string | null; +} + +export function DashboardTiles({ token, organizationId }: DashboardTilesProps) { + const { data, loading, error } = useDashboardSummary(token, organizationId); + + return ( +
+ {error && ( +

+ Kacheln konnten nicht geladen werden: {error} +

+ )} +
+ {tileDefs.map((tile) => { + const card = ( + + + + {tile.label} + + + + {loading ? ( +
+ ) : ( +
{data ? data[tile.key] : "–"}
+ )} + + + ); + return tile.href ? ( + + {card} + + ) : ( +
{card}
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/InviteMemberForm.tsx b/frontend/src/components/dashboard/InviteMemberForm.tsx new file mode 100644 index 0000000..13bfcb9 --- /dev/null +++ b/frontend/src/components/dashboard/InviteMemberForm.tsx @@ -0,0 +1,100 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useAuth } from "@/lib/auth-context"; + +const inviteSchema = z.object({ + email: z.string().email("Bitte gültige E-Mail eingeben"), +}); + +type InviteValues = z.infer; + +interface InviteMemberFormProps { + organizationId: string; +} + +export function InviteMemberForm({ organizationId }: InviteMemberFormProps) { + const { token } = useAuth(); + const [inviteLink, setInviteLink] = useState(null); + const [error, setError] = useState(null); + + const form = useForm({ + resolver: zodResolver(inviteSchema), + defaultValues: { email: "" }, + }); + + async function onSubmit(values: InviteValues) { + setError(null); + setInviteLink(null); + try { + const res = await fetch("/api/invitations", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ organizationId, email: values.email }), + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + throw new Error(body.error ?? `Fehler (${res.status})`); + } + const { token: invitationToken } = (await res.json()) as { token: string }; + setInviteLink(`${window.location.origin}/invite/${invitationToken}`); + form.reset(); + } catch (err) { + setError((err as Error).message); + } + } + + return ( + + + Mitglied einladen + + +
+ + ( + + E-Mail + + + + + + )} + /> + {error &&

{error}

} + + + + {inviteLink && ( +

+ Einladungslink (noch kein E-Mail-Versand):
+ + {inviteLink} + +

+ )} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/OrganizationSwitcher.tsx b/frontend/src/components/dashboard/OrganizationSwitcher.tsx new file mode 100644 index 0000000..17fb0ff --- /dev/null +++ b/frontend/src/components/dashboard/OrganizationSwitcher.tsx @@ -0,0 +1,39 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useOrganization } from "@/lib/organization-context"; + +export function OrganizationSwitcher() { + const { organizations, activeOrganizationId, setActiveOrganizationId, loading, error } = useOrganization(); + + if (loading) { + return
; + } + + if (error) { + return

Vereine konnten nicht geladen werden

; + } + + if (organizations.length === 0) { + return

Kein Verein zugeordnet

; + } + + return ( + + ); +} diff --git a/frontend/src/hooks/use-api-list.ts b/frontend/src/hooks/use-api-list.ts new file mode 100644 index 0000000..e13e773 --- /dev/null +++ b/frontend/src/hooks/use-api-list.ts @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useState } from "react"; + +interface UseApiListResult { + data: T[]; + loading: boolean; + error: string | null; + refresh: () => Promise; +} + +export function useApiList( + token: string | null, + path: string | null, + extractKey: string, +): UseApiListResult { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback( + async (signal?: AbortSignal) => { + if (!token || !path) { + setData([]); + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + const res = await fetch(path, { + headers: { Authorization: `Bearer ${token}` }, + signal, + }); + if (!res.ok) { + throw new Error(`Request failed: ${res.status}`); + } + const body = (await res.json()) as Record; + setData(body[extractKey] ?? []); + } catch (err) { + if ((err as Error).name !== "AbortError") { + setError((err as Error).message); + } + } finally { + setLoading(false); + } + }, + [token, path, extractKey], + ); + + useEffect(() => { + const controller = new AbortController(); + void load(controller.signal); + return () => controller.abort(); + }, [load]); + + return { data, loading, error, refresh: () => load() }; +} diff --git a/frontend/src/hooks/use-dashboard-summary.ts b/frontend/src/hooks/use-dashboard-summary.ts new file mode 100644 index 0000000..91229bc --- /dev/null +++ b/frontend/src/hooks/use-dashboard-summary.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; + +export interface DashboardSummary { + members: number; + departments: number; + teams: number; + chatRooms: number; + invitations: number; +} + +interface UseDashboardSummaryResult { + data: DashboardSummary | null; + loading: boolean; + error: string | null; +} + +export function useDashboardSummary( + token: string | null, + organizationId: string | null, +): UseDashboardSummaryResult { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!token || !organizationId) { + setLoading(false); + return; + } + + const controller = new AbortController(); + + async function load() { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/dashboard/summary?organizationId=${encodeURIComponent(organizationId!)}`, { + headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`Request failed: ${res.status}`); + } + setData((await res.json()) as DashboardSummary); + } catch (err) { + if ((err as Error).name !== "AbortError") { + setError((err as Error).message); + } + } finally { + setLoading(false); + } + } + + void load(); + return () => controller.abort(); + }, [token, organizationId]); + + return { data, loading, error }; +} diff --git a/frontend/src/hooks/use-members.ts b/frontend/src/hooks/use-members.ts new file mode 100644 index 0000000..5835c6e --- /dev/null +++ b/frontend/src/hooks/use-members.ts @@ -0,0 +1,74 @@ +import { useEffect, useState } from "react"; + +export interface Member { + id: string; + firstName: string; + lastName: string; + email: string; + status: "active" | "suspended" | "left"; + createdAt: string; +} + +interface UseMembersResult { + members: Member[]; + total: number; + loading: boolean; + error: string | null; +} + +const PAGE_SIZE = 20; + +export function useMembers( + token: string | null, + organizationId: string | null, + page: number, +): UseMembersResult { + const [members, setMembers] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!token || !organizationId) { + setLoading(false); + return; + } + + const controller = new AbortController(); + + async function load() { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ + organizationId: organizationId!, + limit: String(PAGE_SIZE), + offset: String(page * PAGE_SIZE), + }); + const res = await fetch(`/api/members?${params}`, { + headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`Request failed: ${res.status}`); + } + const body = (await res.json()) as { members: Member[]; total: number }; + setMembers(body.members); + setTotal(body.total); + } catch (err) { + if ((err as Error).name !== "AbortError") { + setError((err as Error).message); + } + } finally { + setLoading(false); + } + } + + void load(); + return () => controller.abort(); + }, [token, organizationId, page]); + + return { members, total, loading, error }; +} + +export { PAGE_SIZE }; diff --git a/frontend/src/lib/auth-context.tsx b/frontend/src/lib/auth-context.tsx new file mode 100644 index 0000000..762baed --- /dev/null +++ b/frontend/src/lib/auth-context.tsx @@ -0,0 +1,79 @@ +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; + +interface AuthUser { + id: string; + email: string; +} + +interface AuthContextValue { + token: string | null; + user: AuthUser | null; + login: (email: string, password: string) => Promise; + setToken: (token: string) => Promise; + logout: () => void; +} + +const AuthContext = createContext(null); + +async function parseError(res: Response): Promise { + try { + const body = (await res.json()) as { error?: string }; + return body.error ?? `Anfrage fehlgeschlagen (${res.status})`; + } catch { + return `Anfrage fehlgeschlagen (${res.status})`; + } +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const [token, setTokenState] = useState(null); + const [user, setUser] = useState(null); + + const applyToken = useCallback(async (newToken: string) => { + const meRes = await fetch("/api/auth/me", { + headers: { Authorization: `Bearer ${newToken}` }, + }); + if (!meRes.ok) { + throw new Error(await parseError(meRes)); + } + const me = (await meRes.json()) as AuthUser; + + setTokenState(newToken); + setUser(me); + }, []); + + const login = useCallback( + async (email: string, password: string) => { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + if (!res.ok) { + throw new Error(await parseError(res)); + } + const { token: newToken } = (await res.json()) as { token: string }; + await applyToken(newToken); + }, + [applyToken], + ); + + const logout = useCallback(() => { + setTokenState(null); + setUser(null); + }, []); + + const value = useMemo( + () => ({ token, user, login, setToken: applyToken, logout }), + [token, user, login, applyToken, logout], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within AuthProvider"); + } + return ctx; +} diff --git a/frontend/src/lib/organization-context.tsx b/frontend/src/lib/organization-context.tsx new file mode 100644 index 0000000..5b83478 --- /dev/null +++ b/frontend/src/lib/organization-context.tsx @@ -0,0 +1,94 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useAuth } from "@/lib/auth-context"; + +export interface Organization { + id: string; + name: string; + slug: string; +} + +interface OrganizationContextValue { + organizations: Organization[]; + activeOrganizationId: string | null; + setActiveOrganizationId: (organizationId: string) => void; + loading: boolean; + error: string | null; + refresh: () => Promise; +} + +const OrganizationContext = createContext(null); + +export function OrganizationProvider({ children }: { children: ReactNode }) { + const { token } = useAuth(); + const [organizations, setOrganizations] = useState([]); + const [activeOrganizationId, setActiveOrganizationId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback( + async (signal?: AbortSignal) => { + if (!token) { + setOrganizations([]); + setActiveOrganizationId(null); + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + const res = await fetch("/api/organizations/mine", { + headers: { Authorization: `Bearer ${token}` }, + signal, + }); + if (!res.ok) { + throw new Error(`Request failed: ${res.status}`); + } + const { organizations: fetched } = (await res.json()) as { organizations: Organization[] }; + setOrganizations(fetched); + setActiveOrganizationId((current) => current ?? fetched[0]?.id ?? null); + } catch (err) { + if ((err as Error).name !== "AbortError") { + setError((err as Error).message); + } + } finally { + setLoading(false); + } + }, + [token], + ); + + useEffect(() => { + const controller = new AbortController(); + void load(controller.signal); + return () => controller.abort(); + }, [load]); + + const selectOrganization = useCallback((organizationId: string) => { + setActiveOrganizationId(organizationId); + }, []); + + const refresh = useCallback(() => load(), [load]); + + const value = useMemo( + () => ({ organizations, activeOrganizationId, setActiveOrganizationId: selectOrganization, loading, error, refresh }), + [organizations, activeOrganizationId, selectOrganization, loading, error, refresh], + ); + + return {children}; +} + +export function useOrganization(): OrganizationContextValue { + const ctx = useContext(OrganizationContext); + if (!ctx) { + throw new Error("useOrganization must be used within OrganizationProvider"); + } + return ctx; +} diff --git a/frontend/src/pages/AcceptInvitationPage.tsx b/frontend/src/pages/AcceptInvitationPage.tsx new file mode 100644 index 0000000..3e61804 --- /dev/null +++ b/frontend/src/pages/AcceptInvitationPage.tsx @@ -0,0 +1,163 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useAuth } from "@/lib/auth-context"; + +interface InvitationPreview { + email: string; + organizationName: string; + requiresPassword: boolean; +} + +function buildSchema(requiresPassword: boolean) { + return z.object({ + firstName: z.string().min(1, "Pflichtfeld"), + lastName: z.string().min(1, "Pflichtfeld"), + password: requiresPassword + ? z.string().min(8, "Mindestens 8 Zeichen") + : z.string().optional(), + }); +} + +export function AcceptInvitationPage() { + const { token } = useParams<{ token: string }>(); + const { setToken } = useAuth(); + const navigate = useNavigate(); + + const [preview, setPreview] = useState(null); + const [loadError, setLoadError] = useState(null); + const [submitError, setSubmitError] = useState(null); + + useEffect(() => { + async function loadPreview() { + try { + const res = await fetch(`/api/invitations/${token}`); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + throw new Error(body.error ?? `Fehler (${res.status})`); + } + setPreview((await res.json()) as InvitationPreview); + } catch (err) { + setLoadError((err as Error).message); + } + } + void loadPreview(); + }, [token]); + + const schema = buildSchema(preview?.requiresPassword ?? true); + const form = useForm>({ + resolver: zodResolver(schema), + defaultValues: { firstName: "", lastName: "", password: "" }, + }); + + async function onSubmit(values: z.infer) { + setSubmitError(null); + try { + const res = await fetch(`/api/invitations/${token}/accept`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + throw new Error(body.error ?? `Fehler (${res.status})`); + } + const { token: authToken } = (await res.json()) as { token: string }; + setToken(authToken); + navigate("/"); + } catch (err) { + setSubmitError((err as Error).message); + } + } + + if (loadError) { + return ( +
+

{loadError}

+
+ ); + } + + if (!preview) { + return ( +
+

Lädt…

+
+ ); + } + + return ( +
+ + + Einladung zu {preview.organizationName} + + +

{preview.email}

+
+ + ( + + Vorname + + + + + + )} + /> + ( + + Nachname + + + + + + )} + /> + {preview.requiresPassword && ( + ( + + Passwort + + + + + + )} + /> + )} + {submitError &&

{submitError}

} + + + +
+
+
+ ); +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..4bcb6c8 --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,35 @@ +import { Button } from "@/components/ui/button"; +import { DashboardTiles } from "@/components/dashboard/DashboardTiles"; +import { OrganizationSwitcher } from "@/components/dashboard/OrganizationSwitcher"; +import { InviteMemberForm } from "@/components/dashboard/InviteMemberForm"; +import { CreateOrganizationForm } from "@/components/dashboard/CreateOrganizationForm"; +import { useAuth } from "@/lib/auth-context"; +import { useOrganization } from "@/lib/organization-context"; + +export function DashboardPage() { + const { token, user, logout } = useAuth(); + const { activeOrganizationId, organizations, loading: organizationsLoading } = useOrganization(); + + return ( +
+
+
+

TalkTime Dashboard

+ {user &&

Angemeldet als {user.email}

} + +
+ +
+ {!organizationsLoading && organizations.length === 0 ? ( + + ) : ( + <> + + {activeOrganizationId && } + + )} +
+ ); +} diff --git a/frontend/src/pages/DepartmentsPage.tsx b/frontend/src/pages/DepartmentsPage.tsx new file mode 100644 index 0000000..fa338cc --- /dev/null +++ b/frontend/src/pages/DepartmentsPage.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent } from "@/components/ui/card"; +import { useAuth } from "@/lib/auth-context"; +import { useOrganization } from "@/lib/organization-context"; +import { useApiList } from "@/hooks/use-api-list"; + +interface Department { + id: string; + name: string; + createdAt: string; +} + +export function DepartmentsPage() { + const { token } = useAuth(); + const { activeOrganizationId } = useOrganization(); + const [name, setName] = useState(""); + const [error, setError] = useState(null); + + const path = activeOrganizationId + ? `/api/departments?organizationId=${activeOrganizationId}` + : null; + const { data: departments, loading, refresh } = useApiList(token, path, "departments"); + + async function onCreate() { + if (!activeOrganizationId || !name.trim()) return; + setError(null); + try { + const res = await fetch("/api/departments", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ organizationId: activeOrganizationId, name }), + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + throw new Error(body.error ?? `Fehler (${res.status})`); + } + setName(""); + await refresh(); + } catch (err) { + setError((err as Error).message); + } + } + + return ( +
+
+

Abteilungen

+ +
+ + + + setName(e.target.value)} + /> + + + + {error &&

{error}

} + +
+ {loading ? ( +

Lädt…

+ ) : departments.length === 0 ? ( +

Keine Abteilungen

+ ) : ( + departments.map((dept) => ( + + {dept.name} + + )) + )} +
+
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..2b5593f --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,9 @@ +import { LoginForm } from "@/components/auth/LoginForm"; + +export function LoginPage() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/pages/MembersPage.tsx b/frontend/src/pages/MembersPage.tsx new file mode 100644 index 0000000..b970907 --- /dev/null +++ b/frontend/src/pages/MembersPage.tsx @@ -0,0 +1,122 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/lib/auth-context"; +import { useOrganization } from "@/lib/organization-context"; +import { useMembers, PAGE_SIZE, type Member } from "@/hooks/use-members"; + +const statusLabel: Record = { + active: "Aktiv", + suspended: "Gesperrt", + left: "Ausgetreten", +}; + +const statusVariant: Record = { + active: "default", + suspended: "destructive", + left: "secondary", +}; + +export function MembersPage() { + const { token } = useAuth(); + const { activeOrganizationId } = useOrganization(); + const [page, setPage] = useState(0); + + const { members, total, loading, error } = useMembers(token, activeOrganizationId, page); + + const hasNextPage = (page + 1) * PAGE_SIZE < total; + + return ( +
+
+

Mitglieder

+ +
+ + {!activeOrganizationId && ( +

Kein Verein ausgewählt.

+ )} + {error &&

Fehler beim Laden: {error}

} + + {activeOrganizationId && ( + <> + + + + Name + E-Mail + Status + Mitglied seit + + + + {loading ? ( + + + Lädt… + + + ) : members.length === 0 ? ( + + + Keine Mitglieder + + + ) : ( + members.map((member) => ( + + + {member.firstName} {member.lastName} + + {member.email} + + + {statusLabel[member.status]} + + + + {new Date(member.createdAt).toLocaleDateString("de-DE")} + + + )) + )} + +
+ +
+

+ {total} Mitglieder gesamt +

+
+ + +
+
+ + )} +
+ ); +} diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..f8470d1 --- /dev/null +++ b/frontend/src/pages/RegisterPage.tsx @@ -0,0 +1,9 @@ +import { RegisterForm } from "@/components/auth/RegisterForm"; + +export function RegisterPage() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/pages/TeamsPage.tsx b/frontend/src/pages/TeamsPage.tsx new file mode 100644 index 0000000..93156f0 --- /dev/null +++ b/frontend/src/pages/TeamsPage.tsx @@ -0,0 +1,156 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useAuth } from "@/lib/auth-context"; +import { useOrganization } from "@/lib/organization-context"; +import { useApiList } from "@/hooks/use-api-list"; + +interface Department { + id: string; + name: string; +} +interface Season { + id: string; + name: string; +} +interface Team { + id: string; + name: string; + departmentId: string; + seasonId: string; +} + +export function TeamsPage() { + const { token } = useAuth(); + const { activeOrganizationId } = useOrganization(); + const [name, setName] = useState(""); + const [departmentId, setDepartmentId] = useState(""); + const [seasonId, setSeasonId] = useState(""); + const [error, setError] = useState(null); + + const orgQuery = activeOrganizationId ? `organizationId=${activeOrganizationId}` : null; + const { data: departments } = useApiList( + token, + orgQuery ? `/api/departments?${orgQuery}` : null, + "departments", + ); + const { data: seasons } = useApiList( + token, + orgQuery ? `/api/seasons?${orgQuery}` : null, + "seasons", + ); + const { data: teams, loading, refresh } = useApiList( + token, + orgQuery ? `/api/teams?${orgQuery}` : null, + "teams", + ); + + async function onCreate() { + if (!activeOrganizationId || !name.trim() || !departmentId || !seasonId) return; + setError(null); + try { + const res = await fetch("/api/teams", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + organizationId: activeOrganizationId, + departmentId, + seasonId, + name, + }), + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + throw new Error(body.error ?? `Fehler (${res.status})`); + } + setName(""); + await refresh(); + } catch (err) { + setError((err as Error).message); + } + } + + const departmentName = (id: string) => departments.find((d) => d.id === id)?.name ?? "?"; + const seasonName = (id: string) => seasons.find((s) => s.id === id)?.name ?? "?"; + + return ( +
+
+

Mannschaften

+ +
+ + + + setName(e.target.value)} /> +
+ + +
+ + {departments.length === 0 && ( +

Erst eine Abteilung anlegen.

+ )} + {seasons.length === 0 && ( +

Erst eine Saison anlegen (Backend: POST /api/seasons).

+ )} +
+
+ {error &&

{error}

} + +
+ {loading ? ( +

Lädt…

+ ) : teams.length === 0 ? ( +

Keine Mannschaften

+ ) : ( + teams.map((team) => ( + + +

{team.name}

+

+ {departmentName(team.departmentId)} · {seasonName(team.seasonId)} +

+
+
+ )) + )} +
+
+ ); +} diff --git a/frontend/src/routes/ProtectedRoute.tsx b/frontend/src/routes/ProtectedRoute.tsx new file mode 100644 index 0000000..7d796e2 --- /dev/null +++ b/frontend/src/routes/ProtectedRoute.tsx @@ -0,0 +1,10 @@ +import { Navigate, Outlet } from "react-router-dom"; +import { useAuth } from "@/lib/auth-context"; + +export function ProtectedRoute() { + const { token } = useAuth(); + if (!token) { + return ; + } + return ; +} diff --git a/synapse/README.md b/synapse/README.md new file mode 100644 index 0000000..5bf31a2 --- /dev/null +++ b/synapse/README.md @@ -0,0 +1,197 @@ +# Synapse Setup (lokal/Server) + +Minimal-Setup für einen Matrix-Synapse-Homeserver als Backend für TalkTime. Zwei Installationswege — Docker (empfohlen, einfacher zu warten) oder Bare-Metal (Debian/Ubuntu, kein Docker verfügbar/gewünscht). + +## Voraussetzungen (beide Varianten) + +- Eine Domain oder lokaler Hostname (z. B. `chat.vereinschat.local` für Dev, `chat.verein.de` für Prod) — das ist `SYNAPSE_SERVER_NAME`, später Teil jeder Matrix-ID, danach nicht mehr änderbar ohne Neuaufsetzen +- PostgreSQL 13+ (SQLite reicht nur für allerkleinste Tests, nicht produktiv) + +--- + +## Option A: Docker (empfohlen) + +Zusätzlich nötig: Docker + Docker Compose + +### 1. Verzeichnis anlegen + +```bash +mkdir -p synapse/data +``` + +### 2. docker-compose.yml + +Liegt bereits als [`docker-compose.yml`](docker-compose.yml) in diesem Ordner. Server-Name und DB-Passwort per `.env` neben der Datei überschreiben (sonst greifen die Defaults `chat.vereinschat.local` / `changeme`): + +```bash +# synapse/.env (nicht committen) +SYNAPSE_SERVER_NAME=chat.verein.de +SYNAPSE_DB_PASSWORD= +``` + +`SYNAPSE_SERVER_NAME` ist der Homeserver-Name — später Teil jeder Matrix-ID (`@user:chat.verein.de`). Danach nicht mehr änderbar, ohne den Server neu aufzusetzen. + +### 3. Konfiguration generieren + +```bash +cd synapse +docker compose run --rm synapse generate +``` + +Erzeugt `data/homeserver.yaml` und `data/*.signing.key`. + +### 4. Datenbank umstellen, Registrierung absichern + +`generate` legt standardmäßig SQLite an und lässt offene Registrierung zu. Vorlage [`homeserver.yaml.sample`](homeserver.yaml.sample) benutzen: + +```bash +cp homeserver.yaml.sample data/homeserver.yaml +``` + +Danach in `data/homeserver.yaml` die Platzhalter `<...>` ersetzen: +- `server_name` / Pfade zur `.signing.key` — Name aus Schritt 3 (Standard: `chat.vereinschat.local`) +- `database.args.password` — dasselbe Passwort wie `SYNAPSE_DB_PASSWORD` +- `registration_shared_secret`, `macaroon_secret_key`, `form_secret` — je ein langer Zufalls-String (`openssl rand -hex 32`) + +Mit `enable_registration: false` läuft Benutzeranlage ausschließlich über TalkTime → Synapse Admin API (kein Selfservice-Signup). + +### 5. Starten + +```bash +docker compose up -d +``` + +Health-Check: + +```bash +curl http://localhost:8008/health +``` + +### 6. Admin-Zugang für TalkTime einrichten + +Ersten Admin-User anlegen: + +```bash +docker compose exec synapse register_new_matrix_user \ + -c /data/homeserver.yaml http://localhost:8008 +``` + +Bei der Abfrage "Make admin? [no]" → `yes` beantworten. + +Für die **Admin API** (die TalkTime nutzt) braucht das Backend keinen Passwort-Login, sondern ein Access Token dieses Admin-Users: + +```bash +curl -XPOST -d '{"type":"m.login.password","user":"","password":""}' \ + "http://localhost:8008/_matrix/client/v3/login" +``` + +Das zurückgegebene `access_token` in `backend/.env` als `SYNAPSE_ADMIN_TOKEN` eintragen. **Nie committen, nie ins Frontend.** + +--- + +## Option B: Bare-Metal (Debian/Ubuntu, kein Docker) + +### 1. Systempakete + venv + +```bash +sudo apt update +sudo apt install -y python3-dev python3-pip python3-venv \ + libpq-dev build-essential libffi-dev libssl-dev libjpeg-dev libxslt1-dev \ + postgresql + +sudo -u postgres createuser synapse -P +sudo -u postgres createdb -O synapse synapse -E UTF8 --locale=C --template=template0 +``` + +`-P` fragt beim Anlegen nach dem Passwort für den `synapse`-DB-User. + +### 2. Synapse installieren + +```bash +sudo mkdir -p /opt/synapse +sudo chown $USER:$USER /opt/synapse +python3 -m venv /opt/synapse/venv +/opt/synapse/venv/bin/pip install --upgrade pip +/opt/synapse/venv/bin/pip install "matrix-synapse[postgres]" +``` + +### 3. Konfiguration generieren + +```bash +cd /opt/synapse +./venv/bin/python -m synapse.app.homeserver \ + --server-name=chat.vereinschat.local \ + --config-path=homeserver.yaml \ + --generate-config \ + --report-stats=no +``` + +Erzeugt `homeserver.yaml` + Signing-Key im aktuellen Verzeichnis. + +### 4. Datenbank umstellen, Registrierung absichern + +Wie Option A, Abschnitt 4 — [`homeserver.yaml.sample`](homeserver.yaml.sample) nach `/opt/synapse/homeserver.yaml` kopieren und Platzhalter ersetzen, nur `database.args.host: localhost` statt `synapse-db`. + +### 5. Als systemd-Dienst starten + +```ini +# /etc/systemd/system/matrix-synapse.service +[Unit] +Description=Matrix Synapse Homeserver +After=network.target postgresql.service + +[Service] +Type=simple +User=synapse +WorkingDirectory=/opt/synapse +ExecStart=/opt/synapse/venv/bin/python -m synapse.app.homeserver -c /opt/synapse/homeserver.yaml +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo useradd -r -d /opt/synapse synapse +sudo chown -R synapse:synapse /opt/synapse +sudo systemctl daemon-reload +sudo systemctl enable --now matrix-synapse +``` + +Health-Check: + +```bash +curl http://localhost:8008/health +``` + +### 6. Admin-Zugang für TalkTime einrichten + +```bash +/opt/synapse/venv/bin/register_new_matrix_user \ + -c /opt/synapse/homeserver.yaml http://localhost:8008 +``` + +Rest identisch zu Option A, Abschnitt 6 (Access Token per Login-Call holen). + +--- + +## Gemeinsame Schritte (beide Optionen) + +### TalkTime-Backend verbinden + +In `backend/.env`: + +```bash +SYNAPSE_BASE_URL=http://localhost:8008 +SYNAPSE_ADMIN_TOKEN= +``` + +### Reverse Proxy + TLS (Produktion) + +Für Produktivbetrieb Synapse hinter nginx mit TLS terminieren (Port 8008 nicht direkt exponieren). Beispiel-nginx-Config und nötige `.well-known`-Delegation folgen in `docs/deployment.md`, sobald das Gesamt-Deployment steht. + +## Troubleshooting + +- **`Connection refused` auf 8008:** Docker: `docker compose logs synapse`. Bare-Metal: `journalctl -u matrix-synapse -f`. +- **Registrierung schlägt fehl:** `enable_registration: false` korrekt? Nutzer nur via `register_new_matrix_user` oder Admin API anlegen. +- **Postgres-Verbindung schlägt fehl:** Docker: `host: synapse-db` muss dem Compose-Service-Namen entsprechen. Bare-Metal: `host: localhost` und `pg_hba.conf` erlaubt lokale Verbindung mit Passwort-Auth. diff --git a/synapse/docker-compose.yml b/synapse/docker-compose.yml new file mode 100644 index 0000000..2a29708 --- /dev/null +++ b/synapse/docker-compose.yml @@ -0,0 +1,24 @@ +services: + synapse-db: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_USER: synapse + POSTGRES_PASSWORD: ${SYNAPSE_DB_PASSWORD:-changeme} + POSTGRES_DB: synapse + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + volumes: + - ./pgdata:/var/lib/postgresql/data + + synapse: + image: matrixdotorg/synapse:latest + restart: unless-stopped + depends_on: + - synapse-db + environment: + SYNAPSE_SERVER_NAME: ${SYNAPSE_SERVER_NAME:-chat.vereinschat.local} + SYNAPSE_REPORT_STATS: "no" + volumes: + - ./data:/data + ports: + - "8008:8008" diff --git a/synapse/homeserver.yaml.sample b/synapse/homeserver.yaml.sample new file mode 100644 index 0000000..a2458c7 --- /dev/null +++ b/synapse/homeserver.yaml.sample @@ -0,0 +1,50 @@ +# Vorlage für data/homeserver.yaml (Docker) bzw. /opt/synapse/homeserver.yaml (Bare-Metal). +# +# Ablauf: +# 1. Signing-Key + Grundgerüst generieren (siehe README, Schritt "Konfiguration generieren") +# 2. Diese Datei über das generierte homeserver.yaml kopieren +# 3. Platzhalter <...> ersetzen +# 4. signing_key_path muss weiterhin auf die zuvor generierte *.signing.key zeigen +# +# NIE mit echten Secrets committen — nur diese .sample-Datei liegt im Repo. + +server_name: "" +pid_file: /data/homeserver.pid +signing_key_path: "/data/.signing.key" + +listeners: + - port: 8008 + tls: false + type: http + x_forwarded: true + bind_addresses: ['0.0.0.0'] + resources: + - names: [client, federation] + compress: false + +database: + name: psycopg2 + args: + user: synapse + password: "" + database: synapse + host: synapse-db + port: 5432 + cp_min: 5 + cp_max: 10 + +log_config: "/data/.log.config" +media_store_path: /data/media_store +max_upload_size: 50M + +# Registrierung nur über TalkTime -> Synapse Admin API, kein öffentliches Signup. +enable_registration: false +registration_shared_secret: "" + +report_stats: false + +macaroon_secret_key: "" +form_secret: "" + +trusted_key_servers: + - server_name: "matrix.org"