import { assertBoostEligibility } from "../domain/boostEligibility"; import { assertRoleNameIsAvailable, assertRolePositionIsSafe, normalizeHexColor, normalizeOptionalHexColor, validateRoleName, type ExistingRole } from "../domain/roleGuards"; export type BoosterRoleRecord = { guildId: string; userId: string; roleId: string; name: string; color: string | null; color2: string | null; icon: string | null; createdAt: number; updatedAt: number; }; export type BoosterRoleStore = { findByUser(guildId: string, userId: string): Promise; findByGuild(guildId: string): Promise; create(record: BoosterRoleRecord): Promise; delete(guildId: string, userId: string): Promise; }; export type RoleRepository = { listRoles(): Promise; createRole(input: { name: string; color: string | null; colors?: { primaryColor: string; secondaryColor?: string; tertiaryColor?: string } | null; permissions: string[]; position: number }): Promise<{ id: string }>; updateRole(roleId: string, input: { name?: string; color?: string | null; colors?: { primaryColor: string; secondaryColor?: string; tertiaryColor?: string } | null; icon?: string | null }): Promise; assignRole(userId: string, roleId: string): Promise; removeRole(userId: string, roleId: string): Promise; deleteRole(roleId: string): Promise; }; export type RoleIcon = { contentType: string; size: number; dataUri: string; }; export type BoosterRoleServiceOptions = { anchorPosition: number; maxIconBytes?: number; now?: () => number; }; export class BoosterRoleService { private readonly now: () => number; constructor( private readonly store: BoosterRoleStore, private readonly roles: RoleRepository, private readonly options: BoosterRoleServiceOptions ) { this.now = options.now ?? Date.now; } async claimRole(input: { guildId: string; userId: string; name: string; color: string | null; color2?: string | null; icon?: RoleIcon | null; isBoosting: boolean; }): Promise { const { guildId, userId, isBoosting } = input; assertBoostEligibility({ isBoosting }); const existingRecord = await this.store.findByUser(guildId, userId); if (existingRecord) { throw new Error("User already has a booster role"); } const name = validateRoleName(input.name); const color = normalizeOptionalHexColor(input.color); const color2 = normalizeOptionalHexColor(input.color2 ?? null); const colors = resolveGradientColors(color, color2); assertRoleNameIsAvailable(name, await this.roles.listRoles()); const position = this.options.anchorPosition - 1; assertRolePositionIsSafe(position, this.options.anchorPosition); const role = await this.roles.createRole({ name, color, colors, permissions: [], position }); let assigned = false; try { if (input.icon) { this.validateRoleIcon(input.icon); await this.roles.updateRole(role.id, { icon: input.icon.dataUri }); } await this.roles.assignRole(userId, role.id); assigned = true; const timestamp = this.now(); const record = { guildId, userId, roleId: role.id, name, color, color2, icon: input.icon?.dataUri ?? null, createdAt: timestamp, updatedAt: timestamp }; await this.store.create(record); return record; } catch (error) { await this.rollbackClaim({ guildId, userId, roleId: role.id, assigned }); throw error; } } async renameRole(input: { guildId: string; userId: string; name: string }): Promise { const { guildId, userId } = input; const record = await this.getUserRecord(guildId, userId); const name = validateRoleName(input.name); assertRoleNameIsAvailable(name, (await this.roles.listRoles()).filter((role) => role.id !== record.roleId)); await this.roles.updateRole(record.roleId, { name }); } async recolorRole(input: { guildId: string; userId: string; color: string; color2?: string | null }): Promise { const { guildId, userId, color, color2 } = input; const record = await this.getUserRecord(guildId, userId); const primaryColor = normalizeHexColor(color); const secondaryColor = normalizeOptionalHexColor(color2 ?? null); const colors = resolveGradientColors(primaryColor, secondaryColor); await this.roles.updateRole(record.roleId, { colors }); } async setRoleIcon(input: { guildId: string; userId: string; icon: RoleIcon }): Promise { const { guildId, userId, icon } = input; const record = await this.getUserRecord(guildId, userId); this.validateRoleIcon(icon); await this.roles.updateRole(record.roleId, { icon: icon.dataUri }); } async deleteRole(input: { guildId: string; userId: string }): Promise { const { guildId, userId } = input; const record = await this.getUserRecord(guildId, userId); await this.roles.deleteRole(record.roleId); await this.store.delete(guildId, userId); } async removeRoleForLostBoost(input: { guildId: string; userId: string }): Promise { const { guildId, userId } = input; const record = await this.store.findByUser(guildId, userId); if (!record) return; await this.roles.deleteRole(record.roleId); await this.store.delete(guildId, userId); } private async rollbackClaim(input: { guildId: string; userId: string; roleId: string; assigned: boolean }): Promise { if (input.assigned) { await ignoreRollbackError(() => this.roles.removeRole(input.userId, input.roleId)); } await ignoreRollbackError(() => this.roles.deleteRole(input.roleId)); await ignoreRollbackError(() => this.store.delete(input.guildId, input.userId)); } private validateRoleIcon(icon: RoleIcon): void { if (!icon.contentType.startsWith("image/")) { throw new Error("Role icon must be an image"); } if (icon.size > (this.options.maxIconBytes ?? 512_000)) { throw new Error("Role icon is too large"); } } private async getUserRecord(guildId: string, userId: string): Promise { const record = await this.store.findByUser(guildId, userId); if (!record) { throw new Error("No booster role found for this user"); } return record; } } async function ignoreRollbackError(action: () => Promise): Promise { try { await action(); } catch { } } function resolveGradientColors(color: string | null, color2: string | null): { primaryColor: string; secondaryColor?: string } | null { if (!color) return null; if (color2) { return { primaryColor: color, secondaryColor: color2 }; } return { primaryColor: color }; }