Files
talktime/backend/src/routes/roles.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

162 lines
5.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 { 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();
},
);
}