feat: enhance booster role functionality with error handling and database migration

This commit is contained in:
MythEclipse
2026-05-21 15:44:17 +07:00
parent cc6975825f
commit c155c94ea2
9 changed files with 238 additions and 25 deletions
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "booster_roles" ALTER COLUMN "created_at" SET DATA TYPE bigint;--> statement-breakpoint
ALTER TABLE "booster_roles" ALTER COLUMN "updated_at" SET DATA TYPE bigint;
+123
View File
@@ -0,0 +1,123 @@
{
"id": "66a1346c-4510-4ec6-a055-a3d0bcf803c3",
"prevId": "e654a614-04fe-4432-aee1-659bc4a0475e",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.booster_roles": {
"name": "booster_roles",
"schema": "",
"columns": {
"guild_id": {
"name": "guild_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role_id": {
"name": "role_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"booster_roles_guild_user_idx": {
"name": "booster_roles_guild_user_idx",
"columns": [
{
"expression": "guild_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"booster_roles_guild_role_idx": {
"name": "booster_roles_guild_role_idx",
"columns": [
{
"expression": "guild_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "role_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -8,6 +8,13 @@
"when": 1779350490110, "when": 1779350490110,
"tag": "0000_simple_titania", "tag": "0000_simple_titania",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1779352840357,
"tag": "0001_misty_wraith",
"breakpoints": true
} }
] ]
} }
+3 -3
View File
@@ -1,4 +1,4 @@
import { integer, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core"; import { bigint, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
export const boosterRoles = pgTable( export const boosterRoles = pgTable(
"booster_roles", "booster_roles",
@@ -9,8 +9,8 @@ export const boosterRoles = pgTable(
name: text("name").notNull(), name: text("name").notNull(),
color: text("color"), color: text("color"),
icon: text("icon"), icon: text("icon"),
createdAt: integer("created_at").notNull(), createdAt: bigint("created_at", { mode: "number" }).notNull(),
updatedAt: integer("updated_at").notNull() updatedAt: bigint("updated_at", { mode: "number" }).notNull()
}, },
(table) => ({ (table) => ({
userIdx: uniqueIndex("booster_roles_guild_user_idx").on(table.guildId, table.userId), userIdx: uniqueIndex("booster_roles_guild_user_idx").on(table.guildId, table.userId),
+12
View File
@@ -120,4 +120,16 @@ describe("handleInteraction", () => {
expect(interaction.replies[0]).toEqual({ content: "Role name is already used", flags: MessageFlags.Ephemeral }); expect(interaction.replies[0]).toEqual({ content: "Role name is already used", flags: MessageFlags.Ephemeral });
}); });
test("hides failed query details from user replies", async () => {
const interaction = new FakeInteraction("claim", { name: "VIP" });
const service = new FakeService();
service.claimRole = async () => {
throw new Error("Failed query: insert into booster_roles params: secret");
};
await handleInteraction(interaction, service, { isBoosting: async () => true });
expect(interaction.replies[0]).toEqual({ content: "Failed to save booster role. Any created role was cleaned up. Try again.", flags: MessageFlags.Ephemeral });
});
}); });
+15 -1
View File
@@ -95,10 +95,24 @@ export async function handleInteraction(
throw new Error("Unknown booster-role subcommand"); throw new Error("Unknown booster-role subcommand");
} catch (error) { } catch (error) {
logger.warn("Booster-role command failed", { error }); logger.warn("Booster-role command failed", { error });
await interaction.reply({ content: error instanceof Error ? error.message : "Command failed", flags: MessageFlags.Ephemeral }); await interaction.reply({ content: toUserErrorMessage(error), flags: MessageFlags.Ephemeral });
} }
} }
function toUserErrorMessage(error: unknown): string {
if (!(error instanceof Error)) return "Command failed";
if (error.message.includes("Failed query")) {
return "Failed to save booster role. Any created role was cleaned up. Try again.";
}
if (error.message.includes("Missing Permissions")) {
return "Bot is missing permissions or role position to manage this role.";
}
return error.message;
}
function requireGuildId(guildId: string | null): string { function requireGuildId(guildId: string | null): string {
if (!guildId) throw new Error("This command can only be used in a server"); if (!guildId) throw new Error("This command can only be used in a server");
return guildId; return guildId;
+23
View File
@@ -17,10 +17,17 @@ class MemoryRoleStore {
} }
} }
class FailingCreateRoleStore extends MemoryRoleStore {
async create(): Promise<void> {
throw new Error("Database insert failed");
}
}
class FakeRoleRepository implements RoleRepository { class FakeRoleRepository implements RoleRepository {
roles = new Map<string, { id: string; name: string; permissions: string[]; position: number; color: string | null; icon?: string | null }>(); roles = new Map<string, { id: string; name: string; permissions: string[]; position: number; color: string | null; icon?: string | null }>();
deletedRoleIds: string[] = []; deletedRoleIds: string[] = [];
assignedRoles: Array<{ userId: string; roleId: string }> = []; assignedRoles: Array<{ userId: string; roleId: string }> = [];
removedRoles: Array<{ userId: string; roleId: string }> = [];
constructor(initialRoles = [{ id: "existing-vip", name: "VIP", permissions: [], position: 1, color: null }]) { constructor(initialRoles = [{ id: "existing-vip", name: "VIP", permissions: [], position: 1, color: null }]) {
for (const role of initialRoles) { for (const role of initialRoles) {
@@ -48,6 +55,10 @@ class FakeRoleRepository implements RoleRepository {
this.assignedRoles.push({ userId, roleId }); this.assignedRoles.push({ userId, roleId });
} }
async removeRole(userId: string, roleId: string) {
this.removedRoles.push({ userId, roleId });
}
async deleteRole(roleId: string) { async deleteRole(roleId: string) {
this.deletedRoleIds.push(roleId); this.deletedRoleIds.push(roleId);
this.roles.delete(roleId); this.roles.delete(roleId);
@@ -130,4 +141,16 @@ describe("BoosterRoleService", () => {
expect(roles.deletedRoleIds).toEqual([claimed.roleId]); expect(roles.deletedRoleIds).toEqual([claimed.roleId]);
expect(await store.findByUser("guild", "user")).toBeNull(); expect(await store.findByUser("guild", "user")).toBeNull();
}); });
test("rolls back created and assigned role when storing claim fails", async () => {
const roles = new FakeRoleRepository([]);
const service = new BoosterRoleService(new FailingCreateRoleStore(), roles, { anchorPosition: 10 });
await expect(service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, isBoosting: true })).rejects.toThrow("Database insert failed");
expect(roles.assignedRoles).toEqual([{ userId: "user", roleId: "created-1" }]);
expect(roles.removedRoles).toEqual([{ userId: "user", roleId: "created-1" }]);
expect(roles.deletedRoleIds).toEqual(["created-1"]);
expect(roles.roles.has("created-1")).toBe(false);
});
}); });
+45 -20
View File
@@ -29,6 +29,7 @@ export type RoleRepository = {
createRole(input: { name: string; color: string | null; permissions: string[]; position: number }): Promise<{ id: string }>; createRole(input: { name: string; color: string | null; permissions: string[]; position: number }): Promise<{ id: string }>;
updateRole(roleId: string, input: { name?: string; color?: string | null; icon?: string | null }): Promise<void>; updateRole(roleId: string, input: { name?: string; color?: string | null; icon?: string | null }): Promise<void>;
assignRole(userId: string, roleId: string): Promise<void>; assignRole(userId: string, roleId: string): Promise<void>;
removeRole(userId: string, roleId: string): Promise<void>;
deleteRole(roleId: string): Promise<void>; deleteRole(roleId: string): Promise<void>;
}; };
@@ -79,27 +80,35 @@ export class BoosterRoleService {
assertRolePositionIsSafe(position, this.options.anchorPosition); assertRolePositionIsSafe(position, this.options.anchorPosition);
const role = await this.roles.createRole({ name, color, permissions: [], position }); const role = await this.roles.createRole({ name, color, permissions: [], position });
if (input.icon) { let assigned = false;
this.validateRoleIcon(input.icon);
await this.roles.updateRole(role.id, { icon: input.icon.dataUri }); 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,
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;
} }
await this.roles.assignRole(userId, role.id);
const timestamp = this.now();
const record = {
guildId,
userId,
roleId: role.id,
name,
color,
icon: input.icon?.dataUri ?? null,
createdAt: timestamp,
updatedAt: timestamp
};
await this.store.create(record);
return record;
} }
async renameRole(input: { guildId: string; userId: string; name: string }): Promise<void> { async renameRole(input: { guildId: string; userId: string; name: string }): Promise<void> {
@@ -139,6 +148,15 @@ export class BoosterRoleService {
await this.store.delete(guildId, userId); await this.store.delete(guildId, userId);
} }
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));
}
private validateRoleIcon(icon: RoleIcon): void { private validateRoleIcon(icon: RoleIcon): void {
if (!icon.contentType.startsWith("image/")) { if (!icon.contentType.startsWith("image/")) {
throw new Error("Role icon must be an image"); throw new Error("Role icon must be an image");
@@ -157,3 +175,10 @@ export class BoosterRoleService {
return record; return record;
} }
} }
async function ignoreRollbackError(action: () => Promise<void>): Promise<void> {
try {
await action();
} catch {
}
}
+8 -1
View File
@@ -2,7 +2,9 @@ import type { ColorResolvable, Guild, Role } from "discord.js";
import type { RoleRepository } from "./boosterRoleService"; import type { RoleRepository } from "./boosterRoleService";
export class DiscordRoleRepository implements RoleRepository { export class DiscordRoleRepository implements RoleRepository {
constructor(private readonly guild: Guild, private readonly anchorRoleId: string | null) {} constructor(private readonly guild: Guild, anchorRoleId: string | null) {
void anchorRoleId;
}
async listRoles() { async listRoles() {
await this.guild.roles.fetch(); await this.guild.roles.fetch();
@@ -34,6 +36,11 @@ export class DiscordRoleRepository implements RoleRepository {
await member.roles.add(roleId); await member.roles.add(roleId);
} }
async removeRole(userId: string, roleId: string): Promise<void> {
const member = await this.guild.members.fetch(userId);
await member.roles.remove(roleId);
}
async deleteRole(roleId: string): Promise<void> { async deleteRole(roleId: string): Promise<void> {
const role = await this.fetchRole(roleId); const role = await this.fetchRole(roleId);
await role.delete(); await role.delete();