2026-05-15 23:03:35 +07:00
|
|
|
import { assertBoostEligibility } from "../domain/boostEligibility";
|
|
|
|
|
import {
|
|
|
|
|
assertRoleNameIsAvailable,
|
|
|
|
|
assertRolePositionIsSafe,
|
|
|
|
|
normalizeHexColor,
|
2026-06-06 00:48:41 +07:00
|
|
|
normalizeOptionalHexColor,
|
2026-05-15 23:03:35 +07:00
|
|
|
validateRoleName,
|
|
|
|
|
type ExistingRole
|
|
|
|
|
} from "../domain/roleGuards";
|
|
|
|
|
|
|
|
|
|
export type BoosterRoleRecord = {
|
|
|
|
|
guildId: string;
|
|
|
|
|
userId: string;
|
|
|
|
|
roleId: string;
|
|
|
|
|
name: string;
|
|
|
|
|
color: string | null;
|
2026-06-06 00:48:41 +07:00
|
|
|
color2: string | null;
|
2026-05-16 00:06:13 +07:00
|
|
|
icon: string | null;
|
2026-05-15 23:03:35 +07:00
|
|
|
createdAt: number;
|
|
|
|
|
updatedAt: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type BoosterRoleStore = {
|
|
|
|
|
findByUser(guildId: string, userId: string): Promise<BoosterRoleRecord | null>;
|
2026-06-06 02:46:38 +07:00
|
|
|
findByGuild(guildId: string): Promise<BoosterRoleRecord[]>;
|
2026-05-15 23:03:35 +07:00
|
|
|
create(record: BoosterRoleRecord): Promise<void>;
|
|
|
|
|
delete(guildId: string, userId: string): Promise<void>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type RoleRepository = {
|
|
|
|
|
listRoles(): Promise<ExistingRole[]>;
|
2026-06-06 00:48:41 +07:00
|
|
|
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<void>;
|
2026-05-16 00:32:29 +07:00
|
|
|
assignRole(userId: string, roleId: string): Promise<void>;
|
2026-05-21 15:44:17 +07:00
|
|
|
removeRole(userId: string, roleId: string): Promise<void>;
|
2026-05-15 23:03:35 +07:00
|
|
|
deleteRole(roleId: string): Promise<void>;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-15 23:23:16 +07:00
|
|
|
export type RoleIcon = {
|
|
|
|
|
contentType: string;
|
|
|
|
|
size: number;
|
|
|
|
|
dataUri: string;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-15 23:03:35 +07:00
|
|
|
export type BoosterRoleServiceOptions = {
|
|
|
|
|
anchorPosition: number;
|
2026-05-15 23:23:16 +07:00
|
|
|
maxIconBytes?: number;
|
2026-05-15 23:03:35 +07:00
|
|
|
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;
|
2026-06-06 00:48:41 +07:00
|
|
|
color2?: string | null;
|
2026-05-16 00:06:13 +07:00
|
|
|
icon?: RoleIcon | null;
|
|
|
|
|
isBoosting: boolean;
|
2026-05-15 23:03:35 +07:00
|
|
|
}): Promise<BoosterRoleRecord> {
|
2026-05-16 00:06:13 +07:00
|
|
|
const { guildId, userId, isBoosting } = input;
|
|
|
|
|
assertBoostEligibility({ isBoosting });
|
2026-05-15 23:03:35 +07:00
|
|
|
|
|
|
|
|
const existingRecord = await this.store.findByUser(guildId, userId);
|
|
|
|
|
if (existingRecord) {
|
|
|
|
|
throw new Error("User already has a booster role");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const name = validateRoleName(input.name);
|
2026-06-06 00:48:41 +07:00
|
|
|
const color = normalizeOptionalHexColor(input.color);
|
|
|
|
|
const color2 = normalizeOptionalHexColor(input.color2 ?? null);
|
|
|
|
|
const colors = resolveGradientColors(color, color2);
|
2026-05-15 23:03:35 +07:00
|
|
|
assertRoleNameIsAvailable(name, await this.roles.listRoles());
|
|
|
|
|
|
|
|
|
|
const position = this.options.anchorPosition - 1;
|
|
|
|
|
assertRolePositionIsSafe(position, this.options.anchorPosition);
|
|
|
|
|
|
2026-06-06 00:48:41 +07:00
|
|
|
const role = await this.roles.createRole({ name, color, colors, permissions: [], position });
|
2026-05-21 15:44:17 +07:00
|
|
|
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,
|
2026-06-06 00:48:41 +07:00
|
|
|
color2,
|
2026-05-21 15:44:17 +07:00
|
|
|
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;
|
2026-05-16 00:06:13 +07:00
|
|
|
}
|
2026-05-15 23:03:35 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async renameRole(input: { guildId: string; userId: string; name: string }): Promise<void> {
|
|
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 00:48:41 +07:00
|
|
|
async recolorRole(input: { guildId: string; userId: string; color: string; color2?: string | null }): Promise<void> {
|
|
|
|
|
const { guildId, userId, color, color2 } = input;
|
2026-05-15 23:03:35 +07:00
|
|
|
const record = await this.getUserRecord(guildId, userId);
|
2026-06-06 00:48:41 +07:00
|
|
|
const primaryColor = normalizeHexColor(color);
|
|
|
|
|
const secondaryColor = normalizeOptionalHexColor(color2 ?? null);
|
|
|
|
|
const colors = resolveGradientColors(primaryColor, secondaryColor);
|
|
|
|
|
await this.roles.updateRole(record.roleId, { colors });
|
2026-05-15 23:03:35 +07:00
|
|
|
}
|
|
|
|
|
|
2026-05-15 23:23:16 +07:00
|
|
|
async setRoleIcon(input: { guildId: string; userId: string; icon: RoleIcon }): Promise<void> {
|
|
|
|
|
const { guildId, userId, icon } = input;
|
|
|
|
|
const record = await this.getUserRecord(guildId, userId);
|
|
|
|
|
this.validateRoleIcon(icon);
|
|
|
|
|
await this.roles.updateRole(record.roleId, { icon: icon.dataUri });
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 23:03:35 +07:00
|
|
|
async deleteRole(input: { guildId: string; userId: string }): Promise<void> {
|
|
|
|
|
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<void> {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 15:44:17 +07:00
|
|
|
private async rollbackClaim(input: { guildId: string; userId: string; roleId: string; assigned: boolean }): Promise<void> {
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 23:23:16 +07:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 23:03:35 +07:00
|
|
|
private async getUserRecord(guildId: string, userId: string): Promise<BoosterRoleRecord> {
|
|
|
|
|
const record = await this.store.findByUser(guildId, userId);
|
|
|
|
|
if (!record) {
|
|
|
|
|
throw new Error("No booster role found for this user");
|
|
|
|
|
}
|
|
|
|
|
return record;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-21 15:44:17 +07:00
|
|
|
|
|
|
|
|
async function ignoreRollbackError(action: () => Promise<void>): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
await action();
|
|
|
|
|
} catch {
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-06 00:48:41 +07:00
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
}
|