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:
patrickandClaude Sonnet 5 committed 2026-09-11 15:07:47 +02:00
1 parent 58a56439a0
commit 5e6ee2c456
55 files changed
+7379 -38

No files matched your search

+5
View File
@@ -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
+57
View File
@@ -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 +-
---
+112
View File
@@ -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 +-
---
+173
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1789131255612,
"tag": "0000_nebulous_prism",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "ai-coding-starter-kit-backend",
"name": "talktime-backend",
"version": "1.0.0",
"private": true,
"type": "module",
+281 -1
View File
@@ -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),
],
);
+20
View File
@@ -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,
});
}
+31
View File
@@ -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);
}
+35
View File
@@ -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 };
});
}
+39
View File
@@ -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,
};
},
);
}
+54
View File
@@ -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);
});
}
+245
View File
@@ -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 });
},
);
}
+101
View File
@@ -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 });
},
);
}
+79
View File
@@ -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 });
});
}
+161
View File
@@ -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();
},
);
}
+59
View File
@@ -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);
});
}
+9
View File
@@ -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" };
});
}
+192
View File
@@ -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();
},
);
}
+29
View File
@@ -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();
});
+131
View File
@@ -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;
}
+138
View File
@@ -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" }),
});
}
+274
View File
@@ -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);
}
+1433
View File
File diff suppressed because it is too large. Load diff
+37 -12
View File
@@ -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`.
+109
View File
@@ -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.
+145
View File
@@ -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.
+167
View File
@@ -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
View File
@@ -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>
+95 -16
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"name": "ai-coding-starter-kit-frontend",
"name": "talktime-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
+26 -6
View File
@@ -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>
<Toaster />
</>
<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>
);
}
+56
View File
@@ -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 };
}
+74
View File
@@ -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 };
+79
View File
@@ -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;
}
+94
View File
@@ -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;
}
+163
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+85
View File
@@ -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>
);
}
+9
View File
@@ -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>
);
}
+122
View File
@@ -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>
);
}
+9
View File
@@ -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>
);
}
+156
View File
@@ -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>
);
}
+10
View File
@@ -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 />;
}
+197
View File
@@ -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.
+24
View File
@@ -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"
+50
View File
@@ -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"