Der Voll-Abgleich hat den Administrator alle fünf Minuten hinausgeworfen: enforceMembership kannte BOOTSTRAP_ADMIN_IDS nicht und sah nur, dass keine seiner Rollen zur Anmeldung berechtigt. Solange das Rollen-Mapping noch leer ist, trifft das zwangsläufig zu — der Notfallzugang war damit genau in der Lage wirkungslos, für die es ihn gibt. Gefunden beim ersten Lauf gegen den echten Discord-Server, nicht im Test.
171 lines
5.8 KiB
TypeScript
171 lines
5.8 KiB
TypeScript
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<number> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
// 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<void> {
|
|
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`)
|
|
}
|