import type { Guild, GuildMember, Role } from 'discord.js' import type { PrismaClient } from '@afis/db' /** * Spiegelt Guild und Mitglieder nach Postgres. * * Die Web-App fragt nie live bei Discord an. Dadurch ist eine Rechteänderung * in Discord in unter einer Sekunde in der App wirksam, ohne dass ein * einziger Request auf die Discord-API wartet. Siehe docs/02-architektur.md. */ /** Rollen, die Discord selbst verwaltet und die niemandem etwas nützen. */ function isRelevantRole(role: Role): boolean { return !role.managed && role.id !== role.guild.id // @everyone hat die Guild-ID } /** * Legt für jede Rolle der Guild eine Zeile in `RoleMapping` an, damit im * Adminbereich nichts von Hand eingetragen werden muss. Vorhandene * Zuordnungen bleiben unangetastet: gespiegelt werden nur Name und Position. * * Rollen, die es nicht mehr gibt, werden nicht gelöscht, sondern auf * `isPresent = false` gesetzt. Beim Umzug auf einen anderen Discord-Server * bleiben sie damit als Vorlage stehen. Siehe docs/13-entscheidungen.md. */ export async function syncRoles(db: PrismaClient, guild: Guild): Promise { const roles = [...guild.roles.cache.values()].filter(isRelevantRole) for (const role of roles) { await db.roleMapping.upsert({ where: { discordRoleId: role.id }, create: { discordRoleId: role.id, label: role.name, sortOrder: role.position, isPresent: true, }, update: { label: role.name, sortOrder: role.position, isPresent: true, }, }) } await db.roleMapping.updateMany({ where: { discordRoleId: { notIn: roles.map((r) => r.id) }, isPresent: true }, data: { isPresent: false }, }) return roles.length } /** * Schreibt Rollen eines Mitglieds. Gibt zurück, ob sich etwas geändert hat, * damit nur dann `pg_notify` ausgelöst wird — der Voll-Abgleich läuft alle * fünf Minuten und soll nicht jedes Mal alle Caches leeren. * * `pg_notify` selbst kommt aus dem Trigger `user_role_notify` auf `UserRole`, * es muss hier nichts von Hand gesendet werden. */ export async function syncMember(db: PrismaClient, member: GuildMember): Promise { const roleIds = [...member.roles.cache.keys()].filter((id) => id !== member.guild.id) const displayName = member.nickname ?? member.user.globalName ?? member.user.username const [existingUser, existingRoles] = await Promise.all([ db.user.findUnique({ where: { id: member.id }, select: { displayName: true, username: true, avatarHash: true, isActive: true }, }), db.userRole.findMany({ where: { userId: member.id }, select: { discordRoleId: true } }), ]) const before = new Set(existingRoles.map((r) => r.discordRoleId)) const after = new Set(roleIds) const rolesChanged = before.size !== after.size || [...after].some((id) => !before.has(id)) const profileChanged = !existingUser || existingUser.displayName !== displayName || existingUser.username !== member.user.username || existingUser.avatarHash !== member.user.avatar || !existingUser.isActive if (!rolesChanged && !profileChanged) return false await db.$transaction([ db.user.upsert({ where: { id: member.id }, create: { id: member.id, username: member.user.username, displayName, avatarHash: member.user.avatar, isActive: true, }, update: { username: member.user.username, displayName, avatarHash: member.user.avatar, isActive: true, }, }), db.userRole.deleteMany({ where: { userId: member.id, discordRoleId: { notIn: roleIds } } }), ...roleIds.map((discordRoleId) => db.userRole.upsert({ where: { userId_discordRoleId: { userId: member.id, discordRoleId } }, create: { userId: member.id, discordRoleId }, update: {}, }), ), ]) return true } /** * Beendet alle Sitzungen eines Nutzers, wenn er keine Rolle mehr trägt, die * zur Anmeldung berechtigt. Der Datensatz bleibt stehen, damit Autorenangaben * in alten Akten gültig bleiben. Siehe docs/03-auth-und-rechte.md. * * Das passiert hier und nicht in der Web-App, weil es sonst erst beim nächsten * Request des Betroffenen griffe — und genau darauf kann man sich nicht * verlassen, wenn jemandem gerade der Zugriff entzogen wurde. */ export async function enforceMembership( db: PrismaClient, userId: string, bootstrapAdminIds: readonly string[] = [], ): Promise { // Der Notfallzugang gilt auch hier. Sonst wirft der Voll-Abgleich den // Administrator alle fünf Minuten hinaus, solange noch keine Rolle zur // Anmeldung berechtigt — und einrichten kann er es dann nie. if (bootstrapAdminIds.includes(userId)) return false const roles = await db.userRole.findMany({ where: { userId }, select: { discordRoleId: true }, }) const granting = await db.roleMapping.count({ where: { discordRoleId: { in: roles.map((r) => r.discordRoleId) }, grantsMembership: true, }, }) if (granting > 0) return false const [{ count }] = await Promise.all([ db.session.deleteMany({ where: { userId } }), db.user.update({ where: { id: userId }, data: { isActive: false } }).catch(() => undefined), ]) if (count > 0) { console.log(`[worker] ${count} Sitzung(en) von ${userId} beendet, keine AfiS-Rolle mehr`) } return true } /** Serveraustritt: Sitzungen beenden, Datensatz behalten. */ export async function handleDeparture(db: PrismaClient, userId: string): Promise { await db.$transaction([ db.session.deleteMany({ where: { userId } }), db.userRole.deleteMany({ where: { userId } }), ]) await db.user.update({ where: { id: userId }, data: { isActive: false } }).catch(() => undefined) console.log(`[worker] ${userId} hat den Server verlassen, Sitzungen beendet`) }