Files
talktime/backend/src/routes/teams.ts
T
patrickandClaude Sonnet 5 5e6ee2c456 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
2026-09-11 15:07:47 +02:00

193 lines
6.3 KiB
TypeScript

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();
},
);
}