- 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
193 lines
6.3 KiB
TypeScript
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();
|
|
},
|
|
);
|
|
}
|