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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TBf3yfnnHzMQ5qhLV2285A
This commit is contained in:
1 parent
58a56439a0
commit
5e6ee2c456
55 files changed
+7378
-37
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 +-
|
||||
|
||||
---
|
||||
@@ -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 +-
|
||||
|
||||
---
|
||||
@@ -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");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1789131255612,
|
||||
"tag": "0000_nebulous_prism",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "ai-coding-starter-kit-backend",
|
||||
"name": "talktime-backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+281
-1
@@ -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),
|
||||
],
|
||||
);
|
||||
@@ -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<string, unknown>;
|
||||
}): Promise<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
return (await findMembership(userId, organizationId)) !== null;
|
||||
}
|
||||
|
||||
export async function requesterIsOrganizationAdmin(userId: string, organizationId: string): Promise<boolean> {
|
||||
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);
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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<void> {
|
||||
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: `<p>Du wurdest zu <strong>${organization?.name ?? "einem Verein"}</strong> auf TalkTime eingeladen.</p><p><a href="${inviteLink}">Einladung annehmen</a></p><p>Link gültig bis ${expiresAt.toLocaleDateString("de-DE")}.</p>`,
|
||||
});
|
||||
|
||||
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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
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();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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" };
|
||||
});
|
||||
}
|
||||
@@ -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<string | null> {
|
||||
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();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<TLSSocket> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<boolean> {
|
||||
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<SynapseUser> {
|
||||
const userId = toMatrixUserId(localpart);
|
||||
return synapseFetch<SynapseUser>(`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
password: params.password,
|
||||
displayname: params.displayName,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUser(userId: string): Promise<SynapseUser | null> {
|
||||
try {
|
||||
return await synapseFetch<SynapseUser>(
|
||||
`/_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<void> {
|
||||
await synapseFetch(`/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ erase }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function reactivateUser(userId: string, newPassword: string): Promise<void> {
|
||||
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<CreateRoomResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await synapseFetch(`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ user_id: userId, reason: "Removed from TalkTime group" }),
|
||||
});
|
||||
}
|
||||
@@ -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<void>;
|
||||
} = {
|
||||
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<T extends SyncJobType>(
|
||||
organizationId: string,
|
||||
type: T,
|
||||
payload: SyncJobPayload[T],
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
+37
-12
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 +-
|
||||
|
||||
---
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI Coding Starter Kit</title>
|
||||
<title>TalkTime</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+95
-16
@@ -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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "ai-coding-starter-kit-frontend",
|
||||
"name": "talktime-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+25
-5
@@ -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 (
|
||||
<>
|
||||
<main className="min-h-screen flex items-center justify-center">
|
||||
<h1 className="text-2xl font-semibold">AI Coding Starter Kit</h1>
|
||||
</main>
|
||||
<AuthProvider>
|
||||
<OrganizationProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/invite/:token" element={<AcceptInvitationPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/members" element={<MembersPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Toaster />
|
||||
</>
|
||||
</OrganizationProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof loginSchema>;
|
||||
|
||||
export function LoginForm() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<LoginValues>({
|
||||
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 (
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>TalkTime Login</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>E-Mail</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" autoComplete="username" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Passwort</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" autoComplete="current-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
||||
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? "Anmelden…" : "Anmelden"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
Noch kein Account?{" "}
|
||||
<a className="underline" href="/register">
|
||||
Registrieren
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof registerSchema>;
|
||||
|
||||
export function RegisterForm() {
|
||||
const { setToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<RegisterValues>({
|
||||
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 (
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Account erstellen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>E-Mail</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" autoComplete="username" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Passwort</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" autoComplete="new-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? "Erstelle…" : "Account erstellen"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof createOrganizationSchema>;
|
||||
|
||||
export function CreateOrganizationForm() {
|
||||
const { token } = useAuth();
|
||||
const { refresh } = useOrganization();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<CreateOrganizationValues>({
|
||||
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 (
|
||||
<Card className="max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Verein anlegen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Kurzname (URL)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="sv-musterstadt" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? "Lege an…" : "Verein anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-2">
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">
|
||||
Kacheln konnten nicht geladen werden: {error}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{tileDefs.map((tile) => {
|
||||
const card = (
|
||||
<Card className={tile.href ? "transition-colors hover:bg-muted/50" : undefined}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{tile.label}
|
||||
</CardTitle>
|
||||
<tile.icon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="h-8 w-16 animate-pulse rounded bg-muted" aria-label="lädt" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{data ? data[tile.key] : "–"}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
return tile.href ? (
|
||||
<Link key={tile.key} to={tile.href}>
|
||||
{card}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={tile.key}>{card}</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof inviteSchema>;
|
||||
|
||||
interface InviteMemberFormProps {
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export function InviteMemberForm({ organizationId }: InviteMemberFormProps) {
|
||||
const { token } = useAuth();
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<InviteValues>({
|
||||
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 (
|
||||
<Card className="max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Mitglied einladen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>E-Mail</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? "Sende…" : "Einladung erstellen"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
{inviteLink && (
|
||||
<p className="mt-4 break-all text-sm text-muted-foreground">
|
||||
Einladungslink (noch kein E-Mail-Versand): <br />
|
||||
<a className="underline" href={inviteLink}>
|
||||
{inviteLink}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 <div className="h-10 w-48 animate-pulse rounded-md bg-muted" aria-label="lädt" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p className="text-sm text-destructive">Vereine konnten nicht geladen werden</p>;
|
||||
}
|
||||
|
||||
if (organizations.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">Kein Verein zugeordnet</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select value={activeOrganizationId ?? undefined} onValueChange={setActiveOrganizationId}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Verein wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{organizations.map((organization) => (
|
||||
<SelectItem key={organization.id} value={organization.id}>
|
||||
{organization.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface UseApiListResult<T> {
|
||||
data: T[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useApiList<T>(
|
||||
token: string | null,
|
||||
path: string | null,
|
||||
extractKey: string,
|
||||
): UseApiListResult<T> {
|
||||
const [data, setData] = useState<T[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<string, T[]>;
|
||||
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() };
|
||||
}
|
||||
@@ -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<DashboardSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
}
|
||||
@@ -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<Member[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
@@ -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<void>;
|
||||
setToken: (token: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
async function parseError(res: Response): Promise<string> {
|
||||
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<string | null>(null);
|
||||
const [user, setUser] = useState<AuthUser | null>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
const OrganizationContext = createContext<OrganizationContextValue | null>(null);
|
||||
|
||||
export function OrganizationProvider({ children }: { children: ReactNode }) {
|
||||
const { token } = useAuth();
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [activeOrganizationId, setActiveOrganizationId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 <OrganizationContext.Provider value={value}>{children}</OrganizationContext.Provider>;
|
||||
}
|
||||
|
||||
export function useOrganization(): OrganizationContextValue {
|
||||
const ctx = useContext(OrganizationContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useOrganization must be used within OrganizationProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -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<InvitationPreview | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(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<z.infer<typeof schema>>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { firstName: "", lastName: "", password: "" },
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof schema>) {
|
||||
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 (
|
||||
<main className="min-h-screen flex items-center justify-center p-6">
|
||||
<p className="text-sm text-destructive">{loadError}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center p-6">
|
||||
<p className="text-sm text-muted-foreground">Lädt…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center p-6">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Einladung zu {preview.organizationName}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{preview.email}</p>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Vorname</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nachname</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{preview.requiresPassword && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Passwort</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" autoComplete="new-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
|
||||
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? "Wird angelegt…" : "Beitreten"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<main className="min-h-screen p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">TalkTime Dashboard</h1>
|
||||
{user && <p className="text-sm text-muted-foreground">Angemeldet als {user.email}</p>}
|
||||
<OrganizationSwitcher />
|
||||
</div>
|
||||
<Button variant="outline" onClick={logout}>
|
||||
Abmelden
|
||||
</Button>
|
||||
</div>
|
||||
{!organizationsLoading && organizations.length === 0 ? (
|
||||
<CreateOrganizationForm />
|
||||
) : (
|
||||
<>
|
||||
<DashboardTiles token={token} organizationId={activeOrganizationId} />
|
||||
{activeOrganizationId && <InviteMemberForm organizationId={activeOrganizationId} />}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
const path = activeOrganizationId
|
||||
? `/api/departments?organizationId=${activeOrganizationId}`
|
||||
: null;
|
||||
const { data: departments, loading, refresh } = useApiList<Department>(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 (
|
||||
<main className="min-h-screen p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">Abteilungen</h1>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/">Zurück zum Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-sm">
|
||||
<CardContent className="pt-6 flex gap-2">
|
||||
<Input
|
||||
placeholder="Name der Abteilung"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Button onClick={onCreate} disabled={!name.trim()}>
|
||||
Anlegen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Lädt…</p>
|
||||
) : departments.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Abteilungen</p>
|
||||
) : (
|
||||
departments.map((dept) => (
|
||||
<Card key={dept.id}>
|
||||
<CardContent className="pt-6">{dept.name}</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { LoginForm } from "@/components/auth/LoginForm";
|
||||
|
||||
export function LoginPage() {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center p-6">
|
||||
<LoginForm />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<Member["status"], string> = {
|
||||
active: "Aktiv",
|
||||
suspended: "Gesperrt",
|
||||
left: "Ausgetreten",
|
||||
};
|
||||
|
||||
const statusVariant: Record<Member["status"], "default" | "secondary" | "destructive"> = {
|
||||
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 (
|
||||
<main className="min-h-screen p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">Mitglieder</h1>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/">Zurück zum Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!activeOrganizationId && (
|
||||
<p className="text-sm text-muted-foreground">Kein Verein ausgewählt.</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-destructive">Fehler beim Laden: {error}</p>}
|
||||
|
||||
{activeOrganizationId && (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>E-Mail</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Mitglied seit</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
Lädt…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : members.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
Keine Mitglieder
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
members.map((member) => (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell>
|
||||
{member.firstName} {member.lastName}
|
||||
</TableCell>
|
||||
<TableCell>{member.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant[member.status]}>
|
||||
{statusLabel[member.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(member.createdAt).toLocaleDateString("de-DE")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{total} Mitglieder gesamt
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!hasNextPage}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { RegisterForm } from "@/components/auth/RegisterForm";
|
||||
|
||||
export function RegisterPage() {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center p-6">
|
||||
<RegisterForm />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<string>("");
|
||||
const [seasonId, setSeasonId] = useState<string>("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const orgQuery = activeOrganizationId ? `organizationId=${activeOrganizationId}` : null;
|
||||
const { data: departments } = useApiList<Department>(
|
||||
token,
|
||||
orgQuery ? `/api/departments?${orgQuery}` : null,
|
||||
"departments",
|
||||
);
|
||||
const { data: seasons } = useApiList<Season>(
|
||||
token,
|
||||
orgQuery ? `/api/seasons?${orgQuery}` : null,
|
||||
"seasons",
|
||||
);
|
||||
const { data: teams, loading, refresh } = useApiList<Team>(
|
||||
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 (
|
||||
<main className="min-h-screen p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">Mannschaften</h1>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/">Zurück zum Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-lg">
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<Input placeholder="Name der Mannschaft" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<div className="flex gap-2">
|
||||
<Select value={departmentId} onValueChange={setDepartmentId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Abteilung" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{departments.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={seasonId} onValueChange={setSeasonId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Saison" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{seasons.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={onCreate} disabled={!name.trim() || !departmentId || !seasonId}>
|
||||
Mannschaft anlegen
|
||||
</Button>
|
||||
{departments.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">Erst eine Abteilung anlegen.</p>
|
||||
)}
|
||||
{seasons.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">Erst eine Saison anlegen (Backend: POST /api/seasons).</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Lädt…</p>
|
||||
) : teams.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Mannschaften</p>
|
||||
) : (
|
||||
teams.map((team) => (
|
||||
<Card key={team.id}>
|
||||
<CardContent className="pt-6 space-y-1">
|
||||
<p className="font-medium">{team.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{departmentName(team.departmentId)} · {seasonName(team.seasonId)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 <Navigate to="/login" replace />;
|
||||
}
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -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=<starkes-passwort>
|
||||
```
|
||||
|
||||
`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":"<admin-user>","password":"<passwort>"}' \
|
||||
"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=<access_token aus Admin-Setup>
|
||||
```
|
||||
|
||||
### 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.
|
||||
@@ -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"
|
||||
@@ -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: "<chat.vereinschat.local>"
|
||||
pid_file: /data/homeserver.pid
|
||||
signing_key_path: "/data/<chat.vereinschat.local>.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: "<db-passwort>"
|
||||
database: synapse
|
||||
host: synapse-db
|
||||
port: 5432
|
||||
cp_min: 5
|
||||
cp_max: 10
|
||||
|
||||
log_config: "/data/<chat.vereinschat.local>.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: "<langer-zufalls-string>"
|
||||
|
||||
report_stats: false
|
||||
|
||||
macaroon_secret_key: "<langer-zufalls-string>"
|
||||
form_secret: "<langer-zufalls-string>"
|
||||
|
||||
trusted_key_servers:
|
||||
- server_name: "matrix.org"
|
||||
Reference in new issue
Block a user