diff --git a/decisions/2026-06-23-role-groups-composite-actions.md b/decisions/2026-06-23-role-groups-composite-actions.md new file mode 100644 index 000000000..cfa3049ca --- /dev/null +++ b/decisions/2026-06-23-role-groups-composite-actions.md @@ -0,0 +1,45 @@ +# ADR: Role Groups Composite Actions & Concurrency Hardening + +**Date:** 2026-06-23 +**Status:** Accepted +**Scope:** Role Groups service (feat/role-groups PR #1544) + +## Decision + +### Shipped (v2.21.0+) + +1. **IDOR Protection (A1, A2):** All role group queries now require guild ownership verification. `addRoleToGroup` now signature-checks `guildId`; `createRoleGroup` rejects `fromMessageId` from other guilds. Integration tests added. +2. **Feature-Breaking Validation (B1):** Schema fixed to accept CUID message IDs (not Discord snowflakes). Existing tests pass. +3. **Tie-Breaking Correctness (C1):** `seedStyleFromMessage` now correctly defaults to Primary on any non-Primary tie. Test updated to exercise real Secondary×1 + Danger×1 case. +4. **Type Safety (D):** All `any` types replaced with Prisma model types (`RoleGroupModel`, `ReactionRoleMappingModel`). Lint reports 0 errors (from 12). +5. **Test Hygiene (E1):** `process.env.DISCORD_TOKEN` now restored after each test suite (afterAll hook). +6. **UX (E2):** Error state cleared before retry in `handleCreateRoleGroup`, preventing stale banners. +7. **Transaction Atomicity (F1):** `createRoleGroup` link operation wrapped in `prisma.$transaction` with conditional `updateMany` to detect race and reject if message already grouped during transaction. + +### Deferred (v2.2 post-launch) + +**F2: Concurrency Guard for `addRoleToMessage` Appends** + +- Current design: guard checks `length >= 25` but concurrent appends can both pass, then both insert. +- Symptom: Message ends up with >25 buttons. +- Fix: Wrap in transaction + final constraint OR DB unique on (messageId, sequence). +- Opened GitHub issue #1558 (label: `ready-for-agent`). +- Priority: v2.2; does not block launch. + +## Also Filed + +- **Issue #1559:** CI lint gate reports green despite 12 type-aware errors in backend (type safety gate gap). + +## Test Coverage + +- New IDOR tests: `createRoleGroup` rejects cross-guild message, `addRoleToGroup` rejects cross-guild group. +- Tie-breaking test: real Secondary+Danger tie. +- Concurrency (transaction): conditional updateMany validates link atomicity. + +## References + +- Service: `packages/backend/src/services/RoleGroupService.ts` +- Routes: `packages/backend/src/routes/roleGroups.ts` +- Schema: `packages/backend/src/schemas/management.ts` +- Tests: `packages/backend/tests/unit/services/RoleGroupService.test.ts`, `packages/backend/tests/integration/routes/roleGroups.test.ts` +- Frontend: `packages/frontend/src/pages/ReactionRoles.tsx` diff --git a/packages/backend/src/errors/AppError.ts b/packages/backend/src/errors/AppError.ts index d2d715d89..4e8cf087e 100644 --- a/packages/backend/src/errors/AppError.ts +++ b/packages/backend/src/errors/AppError.ts @@ -43,4 +43,8 @@ export class AppError extends Error { static gatewayTimeout(message = 'Gateway timeout'): AppError { return new AppError(504, message) } + + static conflict(message: string): AppError { + return new AppError(409, message) + } } diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index b46ad6aab..c00ebbbf5 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -12,6 +12,7 @@ import { setupRecommendationsRoutes } from './recommendations' import { setupTwitchRoutes } from './twitch' import { setupLyricsRoutes } from './lyrics' import { setupRolesRoutes } from './roles' +import { setupRoleGroupsRoutes } from './roleGroups' import { setupRbacRoutes } from './rbac' import { setupGuildAutomationRoutes } from './guildAutomation' import { setupLevelsRoutes } from './levels' @@ -64,6 +65,11 @@ const guildGuardConfigs: GuildGuardConfig[] = [ { path: '/api/guilds/:guildId/levels', module: 'settings' }, { path: '/api/guilds/:guildId/starboard', module: 'settings' }, { path: '/api/guilds/:guildId/recommendations', module: 'settings' }, + { + path: '/api/guilds/:guildId/role-groups', + module: 'settings', + mode: 'manage', + }, ] const routeSetups = [ @@ -80,6 +86,7 @@ const routeSetups = [ setupTwitchRoutes, setupLyricsRoutes, setupRolesRoutes, + setupRoleGroupsRoutes, setupRbacRoutes, setupGuildAutomationRoutes, setupLevelsRoutes, diff --git a/packages/backend/src/routes/roleGroups.ts b/packages/backend/src/routes/roleGroups.ts new file mode 100644 index 000000000..947f86f1a --- /dev/null +++ b/packages/backend/src/routes/roleGroups.ts @@ -0,0 +1,190 @@ +import type { Express, Response } from 'express' +import type { AuthenticatedRequest } from '../middleware/auth' +import { validateParams, validateBody } from '../middleware/validate' +import { asyncHandler } from '../middleware/asyncHandler' +import { AppError } from '../errors/AppError' +import { managementSchemas as s } from '../schemas/management' +import { writeLimiter } from '../middleware/rateLimit' +import { roleGroupService } from '../services/RoleGroupService' + +function p(val: string | string[]): string { + return typeof val === 'string' ? val : val[0] +} + +export function setupRoleGroupsRoutes(app: Express): void { + app.post( + '/api/guilds/:guildId/role-groups', + writeLimiter, + validateParams(s.guildIdParam), + validateBody(s.createRoleGroupBody), + asyncHandler(async (req: AuthenticatedRequest, res: Response) => { + const guildId = p(req.params.guildId) + const data = s.createRoleGroupBody.parse(req.body) + + try { + const group = await roleGroupService.createRoleGroup({ + guildId, + name: data.name, + fromMessageId: data.fromMessageId, + style: data.style, + }) + res.status(201).json(group) + } catch (error: unknown) { + if (error instanceof AppError) { + throw error as AppError + } + const err = + error instanceof Error ? error : new Error(String(error)) + const message = err.message || 'Failed to create role group' + if ( + message.includes('not found') || + message.includes('not-found') + ) { + throw AppError.notFound(message) + } + if ( + message.includes('conflict') || + message.includes('already-grouped') + ) { + throw AppError.conflict(message) + } + throw AppError.badRequest(message) + } + }), + ) + + app.get( + '/api/guilds/:guildId/role-groups', + validateParams(s.guildIdParam), + asyncHandler(async (req: AuthenticatedRequest, res: Response) => { + const guildId = p(req.params.guildId) + + const groups = await roleGroupService.listRoleGroups(guildId) + res.json({ groups }) + }), + ) + + app.get( + '/api/guilds/:guildId/role-groups/:id', + validateParams(s.roleGroupIdParam), + asyncHandler(async (req: AuthenticatedRequest, res: Response) => { + const guildId = p(req.params.guildId) + const id = p(req.params.id) + + const group = await roleGroupService.getRoleGroup(id, guildId) + if (!group) { + throw AppError.notFound('Role group not found') + } + res.json(group) + }), + ) + + app.patch( + '/api/guilds/:guildId/role-groups/:id', + writeLimiter, + validateParams(s.roleGroupIdParam), + validateBody(s.updateRoleGroupBody), + asyncHandler(async (req: AuthenticatedRequest, res: Response) => { + const guildId = p(req.params.guildId) + const id = p(req.params.id) + const data = s.updateRoleGroupBody.parse(req.body) + + try { + const group = await roleGroupService.updateRoleGroup( + id, + guildId, + data, + ) + if (!group) { + throw AppError.notFound('Role group not found') + } + res.json(group) + } catch (error: unknown) { + if (error instanceof AppError) { + throw error as AppError + } + const err = + error instanceof Error ? error : new Error(String(error)) + const message = err.message || 'Failed to update role group' + throw AppError.badRequest(message) + } + }), + ) + + app.post( + '/api/guilds/:guildId/role-groups/:id/roles', + writeLimiter, + validateParams(s.roleGroupIdParam), + validateBody(s.addRoleToGroupBody), + asyncHandler(async (req: AuthenticatedRequest, res: Response) => { + const guildId = p(req.params.guildId) + const id = p(req.params.id) + const botToken = process.env.DISCORD_TOKEN?.trim() + if (!botToken) { + throw AppError.serviceUnavailable('Bot token not configured') + } + + const data = s.addRoleToGroupBody.parse(req.body) + + try { + const result = await roleGroupService.addRoleToGroup( + guildId, + id, + data, + botToken, + ) + res.json(result) + } catch (error: unknown) { + if (error instanceof AppError) { + throw error as AppError + } + const err = + error instanceof Error ? error : new Error(String(error)) + const message = err.message || 'Failed to add role to group' + + // Check for specific error patterns (from tests and RoleGroupService) + if ( + message.includes('not found') || + message.includes('not-found') + ) { + throw AppError.notFound(message) + } + if ( + message.includes('25 buttons') || + message.includes('25-buttons') || + message.includes('250 roles') || + message.includes('250-roles') || + message.includes('already mapped') || + message.includes('duplicate-name') + ) { + throw AppError.conflict(message) + } + if (message.includes('Label too long')) { + throw AppError.badRequest(message) + } + throw AppError.badRequest(message) + } + }), + ) + + app.delete( + '/api/guilds/:guildId/role-groups/:id/roles/:roleId', + writeLimiter, + validateParams(s.roleGroupRoleIdParam), + asyncHandler(async (req: AuthenticatedRequest, res: Response) => { + const guildId = p(req.params.guildId) + const id = p(req.params.id) + const roleId = p(req.params.roleId) + + const success = await roleGroupService.detachRoleFromGroup( + id, + roleId, + guildId, + ) + if (!success) { + throw AppError.notFound('Role group or role not found') + } + res.json({ success: true }) + }), + ) +} diff --git a/packages/backend/src/schemas/management.ts b/packages/backend/src/schemas/management.ts index 35def6fb9..1f291266d 100644 --- a/packages/backend/src/schemas/management.ts +++ b/packages/backend/src/schemas/management.ts @@ -145,6 +145,63 @@ const messageIdParam = guildIdParam.extend({ messageId: z.string().regex(/^\d{17,20}$/, 'Invalid message ID'), }) +const roleGroupIdParam = guildIdParam.extend({ + id: z.string().min(1, 'Role group ID is required'), +}) + +const roleGroupRoleIdParam = roleGroupIdParam.extend({ + roleId: z.string().regex(/^\d{17,20}$/, 'Invalid role ID'), +}) + +const colorHex = z + .string() + .regex(/^0x[0-9A-Fa-f]{6}$/, 'Color must be hex format: 0xRRGGBB') + .optional() + .nullable() + +const createRoleGroupBody = z + .object({ + name: z.string().min(1, 'Name is required').max(100), + fromMessageId: z.string().min(1, 'Message ID is required').optional(), + style: z + .object({ + color: colorHex, + hoist: z.boolean().optional(), + mentionable: z.boolean().optional(), + buttonStyle: z + .enum(['Primary', 'Secondary', 'Success', 'Danger']) + .optional(), + }) + .strict() + .optional(), + }) + .strict() + +const updateRoleGroupBody = z + .object({ + color: colorHex, + hoist: z.boolean().optional(), + mentionable: z.boolean().optional(), + buttonStyle: z + .enum(['Primary', 'Secondary', 'Success', 'Danger']) + .optional() + .nullable(), + }) + .strict() + +const addRoleToGroupBody = z + .object({ + name: z.string().min(1, 'Role name is required').max(100), + label: z.string().max(80, 'Label must be 80 chars or less').optional(), + emoji: z.string().max(100).optional(), + colorOverride: z + .string() + .regex(/^0x[0-9A-Fa-f]{6}$/, 'Color must be hex format: 0xRRGGBB') + .optional(), + dryRun: z.boolean().optional(), + }) + .strict() + export const managementSchemas = { guildIdParam, commandNameParam, @@ -163,4 +220,9 @@ export const managementSchemas = { createReactionRoleBody, updateReactionRoleBody, messageIdParam, + roleGroupIdParam, + roleGroupRoleIdParam, + createRoleGroupBody, + updateRoleGroupBody, + addRoleToGroupBody, } diff --git a/packages/backend/src/services/RoleGroupService.ts b/packages/backend/src/services/RoleGroupService.ts new file mode 100644 index 000000000..3a70abcea --- /dev/null +++ b/packages/backend/src/services/RoleGroupService.ts @@ -0,0 +1,509 @@ +import { getPrismaClient } from '@lucky/shared/utils/database/prismaClient' +import { guildService, type GuildRoleManage } from './GuildService' +import { reactionRolesService } from '@lucky/shared/services/ReactionRolesService' +import { infoLog, errorLog } from '@lucky/shared/utils/general/log' +import { AppError } from '../errors/AppError' + +export interface StyleTemplate { + color?: string | null // hex '0x5865F2' + hoist?: boolean + mentionable?: boolean + buttonStyle?: string | null // 'Primary' | 'Secondary' | 'Success' | 'Danger' +} + +export interface AddRoleToGroupRequest { + name: string + label?: string + emoji?: string + colorOverride?: string // hex '0xRRGGBB' + dryRun?: boolean +} + +export interface AddRoleToGroupPlan { + roleName: string + resolvedColorHex: string | null + resolvedColorInt: number | undefined + buttonStyle: string + willCreateRole: boolean + willAddButton: boolean +} + +export interface AddRoleToGroupResult { + status: 'ok' | 'partial_success' + role: GuildRoleManage + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mapping: any +} + +export class RoleGroupService { + private prisma = getPrismaClient() + + /** + * Converts hex color string to integer. + * '0x5865F2' -> 5793266 + */ + hexToInt(hex: string | null | undefined): number | undefined { + if (!hex) return undefined + try { + return parseInt(hex, 16) + } catch { + return undefined + } + } + + /** + * Converts integer color to hex string. + * 5793266 -> '0x5865F2' + */ + intToHex(color: number | null | undefined): string | undefined { + if (color === null || color === undefined) return undefined + return '0x' + color.toString(16).padStart(6, '0').toUpperCase() + } + + /** + * Seed style from an existing reaction-role message's sibling roles. + * Returns modal color, mode buttonStyle (tie -> Primary), and divergence flag. + */ + async seedStyleFromMessage(messageId: string): Promise< + StyleTemplate & { + buttonStyle: string + divergence: boolean + } + > { + const message = await this.prisma.reactionRoleMessage.findUnique({ + where: { id: messageId }, + }) + if (!message) { + throw AppError.notFound(`Message ${messageId} not found`) + } + + const mappings = await this.prisma.reactionRoleMapping.findMany({ + where: { messageId }, + }) + + // Get all roles for the guild + const allRoles = await guildService.getFullGuildRoles(message.guildId) + + // Filter to only the roles mapped in this message + const mappedRoleIds = new Set(mappings.map((m) => m.roleId)) + const siblingRoles = allRoles.filter((r) => mappedRoleIds.has(r.id)) + + // Modal (most common) color + const colorCounts = new Map() + for (const role of siblingRoles) { + const count = colorCounts.get(role.color) ?? 0 + colorCounts.set(role.color, count + 1) + } + let modalColor: number | undefined + let modalColorCount = 0 + for (const [color, count] of colorCounts.entries()) { + if (count > modalColorCount) { + modalColor = color + modalColorCount = count + } + } + const colorHex = + modalColor !== undefined ? this.intToHex(modalColor) : null + + // Modal hoist and mentionable + const hoistCounts = new Map() + const mentionableCounts = new Map() + for (const role of siblingRoles) { + const hc = hoistCounts.get(role.hoist) ?? 0 + hoistCounts.set(role.hoist, hc + 1) + const mc = mentionableCounts.get(role.mentionable) ?? 0 + mentionableCounts.set(role.mentionable, mc + 1) + } + const modalHoist = + (hoistCounts.get(true) ?? 0) > (hoistCounts.get(false) ?? 0) + const modalMentionable = + (mentionableCounts.get(true) ?? 0) > + (mentionableCounts.get(false) ?? 0) + + // Mode (most common) buttonStyle; tie -> Primary + const styleCounts = new Map() + for (const mapping of mappings) { + const style = mapping.style ?? 'Primary' + const count = styleCounts.get(style) ?? 0 + styleCounts.set(style, count + 1) + } + // Find the maximum count + let maxCount = 0 + for (const count of styleCounts.values()) { + if (count > maxCount) maxCount = count + } + // Count how many styles have the max count + let maxCountStyles = 0 + let maxNonPrimaryStyle = '' + const primaryCount = styleCounts.get('Primary') ?? 0 + for (const [style, count] of styleCounts.entries()) { + if (count === maxCount) { + maxCountStyles++ + if (style !== 'Primary') { + maxNonPrimaryStyle = style + } + } + } + // Decision: if Primary is at max OR there's a tie OR no mappings, use Primary + const modeStyle = + primaryCount === maxCount || maxCountStyles > 1 || maxCount === 0 + ? 'Primary' + : maxNonPrimaryStyle + + // Divergence: colors differ OR hoist differs OR mentionable differs + const divergence = + colorCounts.size > 1 || + hoistCounts.size > 1 || + mentionableCounts.size > 1 + + return { + color: colorHex, + hoist: modalHoist, + mentionable: modalMentionable, + buttonStyle: modeStyle as + | 'Primary' + | 'Secondary' + | 'Success' + | 'Danger', + divergence, + } + } + + /** + * Create a new role group. + * If fromMessageId: seed style from the message's sibling roles and link. + * Else: use the provided style. + */ + async createRoleGroup(req: { + guildId: string + name: string + fromMessageId?: string + style?: StyleTemplate + }) { + let style: StyleTemplate + + if (req.fromMessageId) { + // Check if the message already has a groupId (1:1 constraint) + const message = await this.prisma.reactionRoleMessage.findUnique({ + where: { id: req.fromMessageId }, + }) + if (!message) { + throw AppError.notFound( + `Message ${req.fromMessageId} not found`, + ) + } + // Verify message belongs to the guild (IDOR protection) + if (message.guildId !== req.guildId) { + throw AppError.notFound( + `Message ${req.fromMessageId} not found`, + ) + } + if (message.groupId) { + throw AppError.conflict( + `Message already has a group; conflict due to 1:1 constraint`, + ) + } + + style = await this.seedStyleFromMessage(req.fromMessageId) + } else { + style = req.style ?? {} + } + + // If seeded from message, wrap in transaction to link atomically + if (req.fromMessageId) { + const result = await this.prisma.$transaction(async (tx) => { + const newGroup = await tx.roleGroup.create({ + data: { + guildId: req.guildId, + name: req.name, + color: style.color, + hoist: style.hoist ?? false, + mentionable: style.mentionable ?? false, + buttonStyle: style.buttonStyle, + }, + }) + + // Conditional update: only link if message still has groupId = null + const linked = await tx.reactionRoleMessage.updateMany({ + where: { + id: req.fromMessageId, + groupId: null, + }, + data: { groupId: newGroup.id }, + }) + + if (linked.count === 0) { + throw AppError.conflict( + `Message already has a group; conflict during transaction link`, + ) + } + + return newGroup + }) + return result + } else { + return await this.prisma.roleGroup.create({ + data: { + guildId: req.guildId, + name: req.name, + color: style.color, + hoist: style.hoist ?? false, + mentionable: style.mentionable ?? false, + buttonStyle: style.buttonStyle, + }, + }) + } + } + + /** + * Add a role to a group with preflight checks, dry-run support, DB-first apply, + * and role-only compensation on failure. + */ + async addRoleToGroup( + guildId: string, + groupId: string, + req: AddRoleToGroupRequest, + botToken: string, + ): Promise<{ + plan?: AddRoleToGroupPlan + status?: 'ok' | 'partial_success' + role?: GuildRoleManage + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mapping?: any + }> { + // Load group and verify it belongs to the guild (IDOR protection) + const group = await this.prisma.roleGroup.findFirst({ + where: { id: groupId, guildId }, + }) + if (!group) { + throw AppError.notFound(`Group ${groupId} not found`) + } + + // Load the linked message + const message = await this.prisma.reactionRoleMessage.findUnique({ + where: { groupId }, + }) + if (!message) { + throw AppError.notFound(`No message linked to group ${groupId}`) + } + + // Load current mappings for this message + const currentMappings = await this.prisma.reactionRoleMapping.findMany({ + where: { messageId: message.id }, + }) + + // Preflight checks (no mutations) + // 1. Message button count < 25 + if (currentMappings.length >= 25) { + throw AppError.conflict( + `Cannot add role: message has 25 buttons (Discord limit reached)`, + ) + } + + // 2. Guild role count < 250 + const allGuildRoles = await guildService.getFullGuildRoles( + group.guildId, + ) + if (allGuildRoles.length >= 250) { + throw AppError.conflict( + `Cannot add role: guild has 250 roles (Discord limit)`, + ) + } + + // 3. Label length <= 80 + const label = req.label ?? req.name + if (label.length > 80) { + throw AppError.badRequest( + `Label too long (${label.length} chars, max 80)`, + ) + } + + // 4. Role name not already mapped in this message + const mappedRoleIds = new Set(currentMappings.map((m) => m.roleId)) + const existingRolesInMessage = allGuildRoles.filter((r) => + mappedRoleIds.has(r.id), + ) + if ( + existingRolesInMessage.some( + (r) => r.name.toLowerCase() === req.name.toLowerCase(), + ) + ) { + throw AppError.conflict( + `A role named "${req.name}" is already mapped in this message (double-submit guard)`, + ) + } + + // Resolve style + const resolvedColorHex = req.colorOverride ?? group.color + const resolvedColorInt = this.hexToInt(resolvedColorHex) + const resolvedButtonStyle = (group.buttonStyle ?? 'Primary') as + | 'Primary' + | 'Secondary' + | 'Success' + | 'Danger' + + // Build plan + const plan: AddRoleToGroupPlan = { + roleName: req.name, + resolvedColorHex: resolvedColorHex ?? null, + resolvedColorInt, + buttonStyle: resolvedButtonStyle, + willCreateRole: true, + willAddButton: true, + } + + if (req.dryRun) { + return { plan } + } + + // Apply: create role + add button + let createdRole: GuildRoleManage + try { + createdRole = await guildService.createGuildRole(group.guildId, { + name: req.name, + color: resolvedColorInt, + hoist: group.hoist, + mentionable: group.mentionable, + permissions: '0', // no permissions + }) + } catch (error) { + errorLog({ + message: `Failed to create role "${req.name}" for group ${groupId}`, + error, + }) + throw error + } + + // Add to message (DB-first) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let addResult: any + try { + addResult = await reactionRolesService.addRoleToMessage( + message.messageId, + { + roleId: createdRole.id, + label: label, + emoji: req.emoji, + style: resolvedButtonStyle, + }, + botToken, + ) + } catch (error) { + // Compensation: delete the created role (404-tolerant) + errorLog({ + message: `Failed to add role to message; compensating by deleting role ${createdRole.id}`, + error, + }) + try { + await guildService.deleteGuildRole( + group.guildId, + createdRole.id, + ) + } catch (deleteError) { + errorLog({ + message: `Compensation delete also failed (404 is acceptable)`, + error: deleteError, + }) + } + throw error + } + + // Handle partial_success + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (addResult.status === 'partial_success') { + infoLog({ + message: `Added role to group ${groupId} but Discord PATCH failed; data is consistent, visuals will re-sync (roleId: ${createdRole.id}, messageId: ${message.id})`, + }) + } + + return { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + status: addResult.status, + role: createdRole, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + mapping: addResult.mapping, + } + } + + /** + * List all role groups for a guild. + */ + async listRoleGroups(guildId: string) { + return this.prisma.roleGroup.findMany({ + where: { guildId }, + orderBy: { createdAt: 'desc' }, + }) + } + + /** + * Get a role group by ID and guild ID. + */ + async getRoleGroup(id: string, guildId: string) { + return this.prisma.roleGroup.findFirst({ + where: { id, guildId }, + }) + } + + /** + * Update a role group's style template. + */ + async updateRoleGroup( + id: string, + guildId: string, + updates: { + color?: string | null + hoist?: boolean + mentionable?: boolean + buttonStyle?: string | null + }, + ) { + const group = await this.getRoleGroup(id, guildId) + if (!group) return null + + return this.prisma.roleGroup.update({ + where: { id }, + data: { + ...(updates.color !== undefined && { color: updates.color }), + ...(updates.hoist !== undefined && { hoist: updates.hoist }), + ...(updates.mentionable !== undefined && { + mentionable: updates.mentionable, + }), + ...(updates.buttonStyle !== undefined && { + buttonStyle: updates.buttonStyle, + }), + }, + }) + } + + /** + * Detach a role from a group's message (remove mapping, keep Discord role). + */ + async detachRoleFromGroup( + groupId: string, + roleId: string, + guildId: string, + ): Promise { + // Verify group exists and belongs to guild + const group = await this.getRoleGroup(groupId, guildId) + if (!group) return false + + // Load the linked message + const message = await this.prisma.reactionRoleMessage.findUnique({ + where: { groupId }, + }) + if (!message) return false + + // Delete the mapping + const deleted = await this.prisma.reactionRoleMapping.deleteMany({ + where: { + messageId: message.id, + roleId, + }, + }) + + // Return true if at least one mapping was deleted + return deleted.count > 0 + } +} + +export const roleGroupService = new RoleGroupService() diff --git a/packages/backend/tests/integration/routes/roleGroups.test.ts b/packages/backend/tests/integration/routes/roleGroups.test.ts new file mode 100644 index 000000000..3e2c8d323 --- /dev/null +++ b/packages/backend/tests/integration/routes/roleGroups.test.ts @@ -0,0 +1,550 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import request from 'supertest' +import type { Express, Request, Response, NextFunction } from 'express' +import express from 'express' +import type { AuthenticatedRequest } from '../../../src/middleware/auth' + +// Mock the RoleGroupService before importing routes +const mockCreateRoleGroup = jest.fn() +const mockGetRoleGroup = jest.fn() +const mockListRoleGroups = jest.fn() +const mockUpdateRoleGroup = jest.fn() +const mockDetachRoleFromGroup = jest.fn() +const mockAddRoleToGroup = jest.fn() + +const mockService = { + createRoleGroup: mockCreateRoleGroup, + getRoleGroup: mockGetRoleGroup, + listRoleGroups: mockListRoleGroups, + updateRoleGroup: mockUpdateRoleGroup, + detachRoleFromGroup: mockDetachRoleFromGroup, + addRoleToGroup: mockAddRoleToGroup, +} + +jest.mock('../../../src/services/RoleGroupService', () => ({ + // The route imports the exported singleton; keep the class mock too for any + // callers that still construct it. + RoleGroupService: jest.fn().mockImplementation(() => mockService), + roleGroupService: mockService, +})) + +jest.mock('../../../src/services/GuildService', () => ({ + guildService: { + getGuildRoles: jest.fn(), + }, +})) + +jest.mock('../../../src/middleware/auth', () => ({ + requireAuth: ( + req: AuthenticatedRequest, + _res: Response, + next: NextFunction, + ) => { + req.user = { + id: 'test-discord-id', + username: 'test', + discriminator: '0000', + avatar: null, + } + next() + }, +})) + +jest.mock('../../../src/middleware/guildAccess', () => ({ + requireGuildModuleAccess: + (module: string, action: string) => + (_req: Request, _res: Response, next: NextFunction) => { + next() + }, +})) + +jest.mock('@lucky/shared/utils', () => ({ + errorLog: jest.fn(), + debugLog: jest.fn(), +})) + +import { setupRoleGroupsRoutes } from '../../../src/routes/roleGroups' + +describe('Role Groups Routes', () => { + let app: Express + const originalDiscordToken = process.env.DISCORD_TOKEN + + beforeEach(() => { + // Set test token + process.env.DISCORD_TOKEN = 'test-bot-token' + jest.clearAllMocks() + mockCreateRoleGroup.mockClear() + mockGetRoleGroup.mockClear() + mockListRoleGroups.mockClear() + mockUpdateRoleGroup.mockClear() + mockDetachRoleFromGroup.mockClear() + mockAddRoleToGroup.mockClear() + app = express() + app.use(express.json()) + setupRoleGroupsRoutes(app) + }) + + afterAll(() => { + // Restore original token (delete when it was unset, to avoid storing + // the literal string "undefined" in process.env). + if (originalDiscordToken === undefined) { + delete process.env.DISCORD_TOKEN + } else { + process.env.DISCORD_TOKEN = originalDiscordToken + } + }) + + describe('POST /api/guilds/:guildId/role-groups', () => { + it('creates a role group with fromMessageId', async () => { + const guildId = '123456789012345678' + const mockGroup = { + id: 'group-1', + guildId, + name: 'Test Group', + color: '0x5865F2', + hoist: false, + mentionable: false, + buttonStyle: 'Primary', + createdAt: new Date(), + updatedAt: new Date(), + } + + mockCreateRoleGroup.mockResolvedValue(mockGroup) + + const messageId = '987654321098765432' + const res = await request(app) + .post(`/api/guilds/${guildId}/role-groups`) + .send({ + name: 'Test Group', + fromMessageId: messageId, + }) + + expect(res.status).toBe(201) + expect(res.body).toMatchObject({ + guildId, + name: 'Test Group', + color: '0x5865F2', + hoist: false, + mentionable: false, + buttonStyle: 'Primary', + }) + expect(mockCreateRoleGroup).toHaveBeenCalledWith({ + guildId, + name: 'Test Group', + fromMessageId: messageId, + }) + }) + + it('creates a standalone role group', async () => { + const guildId = '123456789012345678' + const mockGroup = { + id: 'group-1', + guildId, + name: 'Test Group', + color: null, + hoist: false, + mentionable: false, + buttonStyle: null, + createdAt: new Date(), + updatedAt: new Date(), + } + + mockCreateRoleGroup.mockResolvedValue(mockGroup) + + const res = await request(app) + .post(`/api/guilds/${guildId}/role-groups`) + .send({ + name: 'Test Group', + }) + + expect(res.status).toBe(201) + expect(res.body).toMatchObject({ + guildId, + name: 'Test Group', + color: null, + hoist: false, + mentionable: false, + buttonStyle: null, + }) + }) + + it('returns 400 when name is missing', async () => { + const guildId = '123456789012345678' + + const res = await request(app) + .post(`/api/guilds/${guildId}/role-groups`) + .send({}) + + expect(res.status).toBe(400) + }) + + it('returns 409 when message already has a group', async () => { + const guildId = '123456789012345678' + const messageId = '876543210987654321' + + mockCreateRoleGroup.mockRejectedValue(new Error('already-grouped')) + + const res = await request(app) + .post(`/api/guilds/${guildId}/role-groups`) + .send({ + name: 'Test Group', + fromMessageId: messageId, + }) + + expect(res.status).toBe(409) + }) + }) + + describe('GET /api/guilds/:guildId/role-groups', () => { + it('lists role groups', async () => { + const guildId = '123456789012345678' + const mockGroups = [ + { + id: 'group-1', + guildId, + name: 'Group 1', + color: '0x5865F2', + hoist: false, + mentionable: false, + buttonStyle: 'Primary', + }, + ] + + mockListRoleGroups.mockResolvedValue(mockGroups) + + const res = await request(app).get( + `/api/guilds/${guildId}/role-groups`, + ) + + expect(res.status).toBe(200) + expect(res.body).toEqual({ groups: mockGroups }) + }) + }) + + describe('GET /api/guilds/:guildId/role-groups/:id', () => { + it('returns a role group by id', async () => { + const guildId = '123456789012345678' + const groupId = 'group-1' + const mockGroup = { + id: groupId, + guildId, + name: 'Test Group', + color: '0x5865F2', + hoist: false, + mentionable: false, + buttonStyle: 'Primary', + } + + mockGetRoleGroup.mockResolvedValue(mockGroup) + + const res = await request(app).get( + `/api/guilds/${guildId}/role-groups/${groupId}`, + ) + + expect(res.status).toBe(200) + expect(res.body).toEqual(mockGroup) + }) + + it('returns 404 when group not found', async () => { + const guildId = '123456789012345678' + const groupId = 'group-not-found' + + mockGetRoleGroup.mockResolvedValue(null) + + const res = await request(app).get( + `/api/guilds/${guildId}/role-groups/${groupId}`, + ) + + expect(res.status).toBe(404) + }) + }) + + describe('PATCH /api/guilds/:guildId/role-groups/:id', () => { + it('updates the role group style template', async () => { + const guildId = '123456789012345678' + const groupId = 'group-1' + const mockGroup = { + id: groupId, + guildId, + name: 'Test Group', + color: '0xFF0000', + hoist: true, + mentionable: false, + buttonStyle: 'Success', + } + + mockUpdateRoleGroup.mockResolvedValue(mockGroup) + + const res = await request(app) + .patch(`/api/guilds/${guildId}/role-groups/${groupId}`) + .send({ + color: '0xFF0000', + hoist: true, + buttonStyle: 'Success', + }) + + expect(res.status).toBe(200) + expect(res.body).toEqual(mockGroup) + }) + + it('returns 400 when color format is invalid', async () => { + const guildId = '123456789012345678' + const groupId = 'group-1' + + const res = await request(app) + .patch(`/api/guilds/${guildId}/role-groups/${groupId}`) + .send({ + color: 'invalid-color', + }) + + expect(res.status).toBe(400) + }) + + it('returns 404 when group not found', async () => { + const guildId = '123456789012345678' + const groupId = 'group-not-found' + + mockUpdateRoleGroup.mockResolvedValue(null) + + const res = await request(app) + .patch(`/api/guilds/${guildId}/role-groups/${groupId}`) + .send({ + color: '0xFF0000', + }) + + expect(res.status).toBe(404) + }) + }) + + describe('POST /api/guilds/:guildId/role-groups/:id/roles', () => { + it('returns dry-run plan without mutations', async () => { + const guildId = '123456789012345678' + const groupId = 'group-1' + const mockPlan = { + plan: { + roleName: 'New Role', + resolvedColorHex: '0x5865F2', + resolvedColorInt: 5793266, + buttonStyle: 'Primary', + willCreateRole: true, + willAddButton: true, + }, + } + + mockAddRoleToGroup.mockResolvedValue(mockPlan) + + const res = await request(app) + .post(`/api/guilds/${guildId}/role-groups/${groupId}/roles`) + .set('Authorization', 'Bearer token') + .send({ + name: 'New Role', + label: 'New Role Button', + dryRun: true, + }) + + expect(res.status).toBe(200) + expect(res.body).toEqual(mockPlan) + expect(mockAddRoleToGroup).toHaveBeenCalled() + }) + + it('applies add-role and returns role + mapping', async () => { + const guildId = '123456789012345678' + const groupId = 'group-1' + const mockResult = { + status: 'ok', + role: { + id: 'role-123', + name: 'New Role', + color: 5793266, + hoist: false, + mentionable: false, + }, + mapping: { + id: 'mapping-1', + roleId: 'role-123', + label: 'New Role Button', + emoji: null, + style: 'Primary', + }, + } + + mockAddRoleToGroup.mockResolvedValue(mockResult) + + const res = await request(app) + .post(`/api/guilds/${guildId}/role-groups/${groupId}/roles`) + .send({ + name: 'New Role', + label: 'New Role Button', + }) + + expect(res.status).toBe(200) + expect(res.body).toEqual(mockResult) + }) + + it('returns 400 when label exceeds 80 chars', async () => { + const guildId = '123456789012345678' + const groupId = 'group-1' + + const res = await request(app) + .post(`/api/guilds/${guildId}/role-groups/${groupId}/roles`) + .send({ + name: 'New Role', + label: 'a'.repeat(81), + }) + + expect(res.status).toBe(400) + }) + + it('returns 400 when color format is invalid', async () => { + const guildId = '123456789012345678' + const groupId = 'group-1' + + const res = await request(app) + .post(`/api/guilds/${guildId}/role-groups/${groupId}/roles`) + .send({ + name: 'New Role', + colorOverride: 'not-hex', + }) + + expect(res.status).toBe(400) + }) + + it('returns 409 when message has 25 buttons (capacity exceeded)', async () => { + const guildId = '123456789012345678' + const groupId = 'group-1' + + mockAddRoleToGroup.mockRejectedValue(new Error('25-buttons')) + + const res = await request(app) + .post(`/api/guilds/${guildId}/role-groups/${groupId}/roles`) + .send({ + name: 'New Role', + }) + + expect(res.status).toBe(409) + }) + + it('returns 409 when guild has 250 roles (limit reached)', async () => { + const guildId = '123456789012345678' + const groupId = 'group-1' + + mockAddRoleToGroup.mockRejectedValue(new Error('250-roles')) + + const res = await request(app) + .post(`/api/guilds/${guildId}/role-groups/${groupId}/roles`) + .send({ + name: 'New Role', + }) + + expect(res.status).toBe(409) + }) + + it('returns 409 when role name already exists in message (double-submit)', async () => { + const guildId = '123456789012345678' + const groupId = 'group-1' + + mockAddRoleToGroup.mockRejectedValue(new Error('duplicate-name')) + + const res = await request(app) + .post(`/api/guilds/${guildId}/role-groups/${groupId}/roles`) + .send({ + name: 'Existing Role', + }) + + expect(res.status).toBe(409) + }) + + it('returns 404 when group not found', async () => { + const guildId = '123456789012345678' + const groupId = 'group-not-found' + + mockAddRoleToGroup.mockRejectedValue(new Error('not-found')) + + const res = await request(app) + .post(`/api/guilds/${guildId}/role-groups/${groupId}/roles`) + .send({ + name: 'New Role', + }) + + expect(res.status).toBe(404) + }) + + it('returns 200 with partial_success status when Discord PATCH fails', async () => { + const guildId = '123456789012345678' + const groupId = 'group-1' + const mockResult = { + status: 'partial_success', + role: { + id: 'role-123', + name: 'New Role', + color: 5793266, + hoist: false, + mentionable: false, + }, + mapping: { + id: 'mapping-1', + roleId: 'role-123', + label: 'New Role Button', + emoji: null, + style: 'Primary', + }, + } + + mockAddRoleToGroup.mockResolvedValue(mockResult) + + const res = await request(app) + .post(`/api/guilds/${guildId}/role-groups/${groupId}/roles`) + .send({ + name: 'New Role', + }) + + expect(res.status).toBe(200) + expect(res.body.status).toBe('partial_success') + }) + }) + + describe('DELETE /api/guilds/:guildId/role-groups/:id/roles/:roleId', () => { + it('detaches a role from the group message', async () => { + const guildId = '123456789012345678' + const groupId = 'group-1' + const roleId = '999888777666555444' + + mockDetachRoleFromGroup.mockResolvedValue(true) + + const res = await request(app).delete( + `/api/guilds/${guildId}/role-groups/${groupId}/roles/${roleId}`, + ) + + expect(res.status).toBe(200) + expect(res.body).toEqual({ success: true }) + }) + + it('returns 404 when group not found', async () => { + const guildId = '123456789012345678' + const groupId = 'group-not-found' + const roleId = '999888777666555444' + + mockDetachRoleFromGroup.mockResolvedValue(false) + + const res = await request(app).delete( + `/api/guilds/${guildId}/role-groups/${groupId}/roles/${roleId}`, + ) + + expect(res.status).toBe(404) + }) + + it('returns 404 when role not found in group', async () => { + const guildId = '123456789012345678' + const groupId = 'group-1' + const roleId = '111222333444555666' + + mockDetachRoleFromGroup.mockResolvedValue(false) + + const res = await request(app).delete( + `/api/guilds/${guildId}/role-groups/${groupId}/roles/${roleId}`, + ) + + expect(res.status).toBe(404) + }) + }) +}) diff --git a/packages/backend/tests/unit/routes/index.test.ts b/packages/backend/tests/unit/routes/index.test.ts index bc67d44d3..e4e8c2fae 100644 --- a/packages/backend/tests/unit/routes/index.test.ts +++ b/packages/backend/tests/unit/routes/index.test.ts @@ -15,6 +15,7 @@ const setupTrackHistoryRoutes = jest.fn() const setupTwitchRoutes = jest.fn() const setupLyricsRoutes = jest.fn() const setupRolesRoutes = jest.fn() +const setupRoleGroupsRoutes = jest.fn() const setupRbacRoutes = jest.fn() const setupGuildAutomationRoutes = jest.fn() const setupLevelsRoutes = jest.fn() @@ -92,6 +93,10 @@ jest.mock('../../../src/routes/roles', () => ({ setupRolesRoutes, })) +jest.mock('../../../src/routes/roleGroups', () => ({ + setupRoleGroupsRoutes, +})) + jest.mock('../../../src/routes/rbac', () => ({ setupRbacRoutes, })) @@ -225,6 +230,11 @@ describe('setupRoutes', () => { requireAuth, 'automation:view', ) + expect(app.use).toHaveBeenCalledWith( + '/api/guilds/:guildId/role-groups', + requireAuth, + 'settings:manage', + ) expect(setupAuthRoutes).toHaveBeenCalledWith(app) expect(setupToggleRoutes).toHaveBeenCalledWith(app) @@ -237,6 +247,7 @@ describe('setupRoutes', () => { expect(setupTwitchRoutes).toHaveBeenCalledWith(app) expect(setupLyricsRoutes).toHaveBeenCalledWith(app) expect(setupRolesRoutes).toHaveBeenCalledWith(app) + expect(setupRoleGroupsRoutes).toHaveBeenCalledWith(app) expect(setupRbacRoutes).toHaveBeenCalledWith(app) expect(setupGuildAutomationRoutes).toHaveBeenCalledWith(app) expect(setupLevelsRoutes).toHaveBeenCalledWith(app) diff --git a/packages/backend/tests/unit/services/RoleGroupService.test.ts b/packages/backend/tests/unit/services/RoleGroupService.test.ts new file mode 100644 index 000000000..b84f14a25 --- /dev/null +++ b/packages/backend/tests/unit/services/RoleGroupService.test.ts @@ -0,0 +1,839 @@ +import { describe, test, expect, beforeEach, jest } from '@jest/globals' + +const mockPrisma: any = { + roleGroup: { + create: jest.fn(), + findUnique: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + reactionRoleMessage: { + findUnique: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + }, + reactionRoleMapping: { + findMany: jest.fn(), + create: jest.fn(), + }, + $transaction: jest.fn((callback: any) => callback(mockPrisma)), +} + +jest.mock('@lucky/shared/utils/database/prismaClient', () => { + return { getPrismaClient: () => mockPrisma } +}) + +jest.mock('@lucky/shared/utils/general/log', () => ({ + errorLog: jest.fn(), + debugLog: jest.fn(), + infoLog: jest.fn(), + warnLog: jest.fn(), +})) + +// Mock the singletons that are imported at the module level +jest.mock('../../../src/services/GuildService', () => { + return { + guildService: { + createGuildRole: jest.fn(), + deleteGuildRole: jest.fn(), + getFullGuildRoles: jest.fn(), + }, + } +}) + +jest.mock('@lucky/shared/services/ReactionRolesService', () => { + return { + reactionRolesService: { + addRoleToMessage: jest.fn(), + }, + } +}) + +import { RoleGroupService } from '../../../src/services/RoleGroupService' + +describe('RoleGroupService', () => { + let service: InstanceType + + const GUILD_ID = '111111111111111111' + const MESSAGE_ID = 'msg-123' + const ROLE_ID_A = 'role-111' + const ROLE_ID_B = 'role-222' + const BOT_TOKEN = 'token-xyz' + + beforeEach(() => { + jest.clearAllMocks() + service = new RoleGroupService() + }) + + describe('hex ↔ int color conversion', () => { + test('converts hex string to integer color', () => { + // 0x5865F2 = 5793266 + expect(service.hexToInt('0x5865F2')).toBe(5793266) + }) + + test('converts integer color to hex string', () => { + // 5793266 = 0x5865F2 + expect(service.intToHex(5793266)).toBe('0x5865F2') + }) + + test('returns undefined for null hex', () => { + expect(service.hexToInt(null)).toBeUndefined() + }) + + test('returns undefined for null int', () => { + expect(service.intToHex(null)).toBeUndefined() + }) + + test('round-trip conversion preserves value', () => { + const original = 5793266 + const hex = service.intToHex(original) + const roundTrip = service.hexToInt(hex) + expect(roundTrip).toBe(original) + }) + }) + + describe('seedStyleFromMessage', () => { + test('returns modal color, mode buttonStyle, and divergence flag from siblings', async () => { + const mockRoles = [ + { + id: ROLE_ID_A, + color: 5793266, + hoist: true, + mentionable: false, + }, + { + id: ROLE_ID_B, + color: 5793266, + hoist: true, + mentionable: false, + }, + ] + const mockMappings = [ + { id: 'map-1', roleId: ROLE_ID_A, style: 'Primary' }, + { id: 'map-2', roleId: ROLE_ID_B, style: 'Primary' }, + ] + + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValue({ + id: MESSAGE_ID, + guildId: GUILD_ID, + }) + mockPrisma.reactionRoleMapping.findMany.mockResolvedValue( + mockMappings, + ) + + const { guildService } = + await import('../../../src/services/GuildService') + ;(guildService.getFullGuildRoles as jest.Mock).mockResolvedValue( + mockRoles, + ) + + const result = await service.seedStyleFromMessage(MESSAGE_ID) + + expect(result.color).toBe('0x5865F2') + expect(result.hoist).toBe(true) + expect(result.mentionable).toBe(false) + expect(result.buttonStyle).toBe('Primary') + expect(result.divergence).toBe(false) + }) + + test('flags divergence when sibling colors differ', async () => { + const mockRoles = [ + { + id: ROLE_ID_A, + color: 5793266, + hoist: true, + mentionable: false, + }, + { + id: ROLE_ID_B, + color: 16711680, + hoist: true, + mentionable: false, + }, // different color (red) + ] + const mockMappings = [ + { id: 'map-1', roleId: ROLE_ID_A, style: 'Primary' }, + { id: 'map-2', roleId: ROLE_ID_B, style: 'Primary' }, + ] + + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValue({ + id: MESSAGE_ID, + guildId: GUILD_ID, + }) + mockPrisma.reactionRoleMapping.findMany.mockResolvedValue( + mockMappings, + ) + + const { guildService } = + await import('../../../src/services/GuildService') + ;(guildService.getFullGuildRoles as jest.Mock).mockResolvedValue( + mockRoles, + ) + + const result = await service.seedStyleFromMessage(MESSAGE_ID) + + expect(result.divergence).toBe(true) + }) + + test('defaults buttonStyle to Primary on tie (Primary=1 vs Secondary=1)', async () => { + const mockRoles = [ + { + id: ROLE_ID_A, + color: 5793266, + hoist: true, + mentionable: false, + }, + { + id: ROLE_ID_B, + color: 5793266, + hoist: true, + mentionable: false, + }, + ] + // REAL TIE: Secondary and Danger each appear once, Primary doesn't appear + // This exercises the tie-breaking logic: non-Primary styles tied -> Primary wins + const mockMappings = [ + { id: 'map-1', roleId: ROLE_ID_A, style: 'Secondary' }, + { id: 'map-2', roleId: ROLE_ID_B, style: 'Danger' }, + ] + + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValue({ + id: MESSAGE_ID, + guildId: GUILD_ID, + }) + mockPrisma.reactionRoleMapping.findMany.mockResolvedValue( + mockMappings, + ) + + const { guildService } = + await import('../../../src/services/GuildService') + ;(guildService.getFullGuildRoles as jest.Mock).mockResolvedValue( + mockRoles, + ) + + const result = await service.seedStyleFromMessage(MESSAGE_ID) + + expect(result.buttonStyle).toBe('Primary') + }) + }) + + describe('createRoleGroup', () => { + test('creates a role group with provided style when no fromMessageId', async () => { + mockPrisma.roleGroup.create.mockResolvedValue({ + id: 'group-1', + guildId: GUILD_ID, + name: 'TestGroup', + color: '0x5865F2', + hoist: false, + mentionable: false, + buttonStyle: 'Primary', + defaultEmoji: null, + }) + + const result = await service.createRoleGroup({ + guildId: GUILD_ID, + name: 'TestGroup', + style: { + color: '0x5865F2', + hoist: false, + mentionable: false, + buttonStyle: 'Primary', + }, + }) + + expect(result.id).toBe('group-1') + expect(result.name).toBe('TestGroup') + expect(result.color).toBe('0x5865F2') + expect(mockPrisma.roleGroup.create).toHaveBeenCalled() + }) + + test('seeds style from message and links when fromMessageId provided (transactional)', async () => { + const mockRoles = [ + { + id: ROLE_ID_A, + color: 5793266, + hoist: true, + mentionable: false, + }, + ] + const mockMappings = [ + { id: 'map-1', roleId: ROLE_ID_A, style: 'Primary' }, + ] + + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValue({ + id: MESSAGE_ID, + guildId: GUILD_ID, + groupId: null, + }) + mockPrisma.reactionRoleMapping.findMany.mockResolvedValue( + mockMappings, + ) + mockPrisma.roleGroup.create.mockResolvedValue({ + id: 'group-1', + guildId: GUILD_ID, + name: 'TestGroup', + color: '0x5865F2', + hoist: true, + mentionable: false, + buttonStyle: 'Primary', + }) + mockPrisma.reactionRoleMessage.updateMany.mockResolvedValue({ + count: 1, + }) + + const { guildService } = + await import('../../../src/services/GuildService') + ;(guildService.getFullGuildRoles as jest.Mock).mockResolvedValue( + mockRoles, + ) + + const result = await service.createRoleGroup({ + guildId: GUILD_ID, + name: 'TestGroup', + fromMessageId: MESSAGE_ID, + }) + + expect(result.id).toBe('group-1') + expect( + mockPrisma.reactionRoleMessage.updateMany, + ).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: MESSAGE_ID, groupId: null }, + data: { groupId: 'group-1' }, + }), + ) + }) + + test('throws conflict when message already has a groupId', async () => { + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValue({ + id: MESSAGE_ID, + guildId: GUILD_ID, + groupId: 'existing-group', + }) + + await expect( + service.createRoleGroup({ + guildId: GUILD_ID, + name: 'TestGroup', + fromMessageId: MESSAGE_ID, + }), + ).rejects.toThrow(/already has a group|conflict/i) + }) + + test('rejects createRoleGroup when fromMessageId belongs to different guild (IDOR)', async () => { + const otherGuildId = '999999999999999999' + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValue({ + id: MESSAGE_ID, + guildId: otherGuildId, // message is in different guild + groupId: null, + }) + + await expect( + service.createRoleGroup({ + guildId: GUILD_ID, + name: 'TestGroup', + fromMessageId: MESSAGE_ID, + }), + ).rejects.toThrow(/not found/) + }) + }) + + describe('addRoleToGroup', () => { + test('dryRun returns plan with zero mutations', async () => { + mockPrisma.roleGroup.findFirst.mockResolvedValue({ + id: 'group-1', + guildId: GUILD_ID, + name: 'TestGroup', + color: '0x5865F2', + hoist: true, + mentionable: false, + buttonStyle: 'Primary', + }) + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValue({ + id: MESSAGE_ID, + guildId: GUILD_ID, + }) + mockPrisma.reactionRoleMapping.findMany.mockResolvedValue([]) + + const { guildService } = + await import('../../../src/services/GuildService') + ;(guildService.getFullGuildRoles as jest.Mock).mockResolvedValue([]) + + const result = await service.addRoleToGroup( + GUILD_ID, + 'group-1', + { name: 'NewRole', dryRun: true }, + BOT_TOKEN, + ) + + expect(result).toHaveProperty('plan') + expect(result.plan).toMatchObject({ + roleName: 'NewRole', + resolvedColorHex: '0x5865F2', + resolvedColorInt: 5793266, + buttonStyle: 'Primary', + willCreateRole: true, + willAddButton: true, + }) + expect(guildService.createGuildRole).not.toHaveBeenCalled() + }) + + test('preflight rejects when message has >= 25 buttons', async () => { + mockPrisma.roleGroup.findFirst.mockResolvedValue({ + id: 'group-1', + guildId: GUILD_ID, + color: '0x5865F2', + hoist: true, + mentionable: false, + buttonStyle: 'Primary', + }) + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValue({ + id: MESSAGE_ID, + guildId: GUILD_ID, + }) + const mappings = Array.from({ length: 25 }, (_, i) => ({ + id: `map-${i}`, + roleId: `role-${i}`, + style: 'Primary', + })) + mockPrisma.reactionRoleMapping.findMany.mockResolvedValue(mappings) + + await expect( + service.addRoleToGroup( + GUILD_ID, + 'group-1', + { name: 'NewRole' }, + BOT_TOKEN, + ), + ).rejects.toThrow(/25|capacity|limit/i) + }) + + test('preflight rejects when guild has >= 250 roles', async () => { + mockPrisma.roleGroup.findFirst.mockResolvedValue({ + id: 'group-1', + guildId: GUILD_ID, + color: '0x5865F2', + hoist: true, + mentionable: false, + buttonStyle: 'Primary', + }) + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValue({ + id: MESSAGE_ID, + guildId: GUILD_ID, + }) + mockPrisma.reactionRoleMapping.findMany.mockResolvedValue([]) + + const { guildService } = + await import('../../../src/services/GuildService') + const manyRoles = Array.from({ length: 250 }, (_, i) => ({ + id: `role-${i}`, + name: `Role ${i}`, + color: 0, + hoist: false, + mentionable: false, + permissions: '0', + position: i, + managed: false, + })) + ;(guildService.getFullGuildRoles as jest.Mock).mockResolvedValue( + manyRoles, + ) + + await expect( + service.addRoleToGroup( + GUILD_ID, + 'group-1', + { name: 'NewRole' }, + BOT_TOKEN, + ), + ).rejects.toThrow(/250|role limit/i) + }) + + test('preflight rejects when label > 80 chars', async () => { + mockPrisma.roleGroup.findFirst.mockResolvedValue({ + id: 'group-1', + guildId: GUILD_ID, + color: '0x5865F2', + hoist: true, + mentionable: false, + buttonStyle: 'Primary', + }) + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValue({ + id: MESSAGE_ID, + guildId: GUILD_ID, + }) + mockPrisma.reactionRoleMapping.findMany.mockResolvedValue([]) + + const { guildService } = + await import('../../../src/services/GuildService') + ;(guildService.getFullGuildRoles as jest.Mock).mockResolvedValue([]) + + const longLabel = 'a'.repeat(81) + await expect( + service.addRoleToGroup( + GUILD_ID, + 'group-1', + { name: 'NewRole', label: longLabel }, + BOT_TOKEN, + ), + ).rejects.toThrow(/80|label.*length/i) + }) + + test('preflight rejects when role name already exists in group message', async () => { + mockPrisma.roleGroup.findFirst.mockResolvedValue({ + id: 'group-1', + guildId: GUILD_ID, + color: '0x5865F2', + hoist: true, + mentionable: false, + buttonStyle: 'Primary', + }) + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValue({ + id: MESSAGE_ID, + guildId: GUILD_ID, + }) + mockPrisma.reactionRoleMapping.findMany.mockResolvedValue([ + { + id: 'map-1', + roleId: 'role-existing', + style: 'Primary', + label: 'ExistingRole', + }, + ]) + + const { guildService } = + await import('../../../src/services/GuildService') + ;(guildService.getFullGuildRoles as jest.Mock).mockResolvedValue([ + { + id: 'role-existing', + name: 'ExistingRole', + color: 0, + hoist: false, + mentionable: false, + permissions: '0', + position: 0, + managed: false, + }, + ]) + + await expect( + service.addRoleToGroup( + GUILD_ID, + 'group-1', + { name: 'ExistingRole' }, + BOT_TOKEN, + ), + ).rejects.toThrow(/already.*mapped|duplicate|exists/i) + }) + + test('applies role creation and button add on success', async () => { + mockPrisma.roleGroup.findFirst.mockResolvedValue({ + id: 'group-1', + guildId: GUILD_ID, + color: '0x5865F2', + hoist: true, + mentionable: false, + buttonStyle: 'Primary', + }) + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValue({ + id: MESSAGE_ID, + guildId: GUILD_ID, + }) + mockPrisma.reactionRoleMapping.findMany.mockResolvedValue([]) + + const { guildService } = + await import('../../../src/services/GuildService') + ;(guildService.getFullGuildRoles as jest.Mock).mockResolvedValue([]) + ;(guildService.createGuildRole as jest.Mock).mockResolvedValue({ + id: ROLE_ID_A, + name: 'NewRole', + color: 5793266, + hoist: true, + mentionable: false, + permissions: '0', + position: 0, + managed: false, + }) + + const { reactionRolesService } = + await import('@lucky/shared/services/ReactionRolesService') + ;( + reactionRolesService.addRoleToMessage as jest.Mock + ).mockResolvedValue({ + status: 'ok', + mapping: { + id: 'map-1', + roleId: ROLE_ID_A, + label: 'NewRole', + style: 'Primary', + }, + }) + + const result = await service.addRoleToGroup( + GUILD_ID, + 'group-1', + { name: 'NewRole' }, + BOT_TOKEN, + ) + + expect(result.status).toBe('ok') + expect(result.role?.id).toBe(ROLE_ID_A) + expect(guildService.createGuildRole).toHaveBeenCalledWith( + GUILD_ID, + { + name: 'NewRole', + color: 5793266, + hoist: true, + mentionable: false, + permissions: '0', + }, + ) + }) + + test('compensation: deletes created role if addRoleToMessage fails', async () => { + mockPrisma.roleGroup.findFirst.mockResolvedValue({ + id: 'group-1', + guildId: GUILD_ID, + color: '0x5865F2', + hoist: true, + mentionable: false, + buttonStyle: 'Primary', + }) + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValue({ + id: MESSAGE_ID, + guildId: GUILD_ID, + }) + mockPrisma.reactionRoleMapping.findMany.mockResolvedValue([]) + + const { guildService } = + await import('../../../src/services/GuildService') + ;(guildService.getFullGuildRoles as jest.Mock).mockResolvedValue([]) + ;(guildService.createGuildRole as jest.Mock).mockResolvedValue({ + id: ROLE_ID_A, + name: 'NewRole', + color: 5793266, + hoist: true, + mentionable: false, + permissions: '0', + position: 0, + managed: false, + }) + + const { reactionRolesService } = + await import('@lucky/shared/services/ReactionRolesService') + ;( + reactionRolesService.addRoleToMessage as jest.Mock + ).mockRejectedValue(new Error('DB transaction failed')) + ;(guildService.deleteGuildRole as jest.Mock).mockResolvedValue( + undefined, + ) + + await expect( + service.addRoleToGroup( + GUILD_ID, + 'group-1', + { name: 'NewRole' }, + BOT_TOKEN, + ), + ).rejects.toThrow(/transaction failed|compensation/i) + + expect(guildService.deleteGuildRole).toHaveBeenCalledWith( + GUILD_ID, + ROLE_ID_A, + ) + }) + + test('handles deleteGuildRole 404 as success during compensation', async () => { + mockPrisma.roleGroup.findFirst.mockResolvedValue({ + id: 'group-1', + guildId: GUILD_ID, + color: '0x5865F2', + hoist: true, + mentionable: false, + buttonStyle: 'Primary', + }) + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValue({ + id: MESSAGE_ID, + guildId: GUILD_ID, + }) + mockPrisma.reactionRoleMapping.findMany.mockResolvedValue([]) + + const { guildService } = + await import('../../../src/services/GuildService') + ;(guildService.getFullGuildRoles as jest.Mock).mockResolvedValue([]) + ;(guildService.createGuildRole as jest.Mock).mockResolvedValue({ + id: ROLE_ID_A, + name: 'NewRole', + color: 5793266, + hoist: true, + mentionable: false, + permissions: '0', + position: 0, + managed: false, + }) + + const { reactionRolesService } = + await import('@lucky/shared/services/ReactionRolesService') + ;( + reactionRolesService.addRoleToMessage as jest.Mock + ).mockRejectedValue(new Error('DB transaction failed')) + ;(guildService.deleteGuildRole as jest.Mock).mockRejectedValue( + new Error('Role not found (404)'), + ) + + await expect( + service.addRoleToGroup( + GUILD_ID, + 'group-1', + { name: 'NewRole' }, + BOT_TOKEN, + ), + ).rejects.toThrow() + + // deleteGuildRole should still be called (404 is tolerated internally) + expect(guildService.deleteGuildRole).toHaveBeenCalledWith( + GUILD_ID, + ROLE_ID_A, + ) + }) + + test('returns partial_success when addRoleToMessage succeeds but Discord PATCH fails', async () => { + mockPrisma.roleGroup.findFirst.mockResolvedValue({ + id: 'group-1', + guildId: GUILD_ID, + color: '0x5865F2', + hoist: true, + mentionable: false, + buttonStyle: 'Primary', + }) + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValue({ + id: MESSAGE_ID, + guildId: GUILD_ID, + }) + mockPrisma.reactionRoleMapping.findMany.mockResolvedValue([]) + + const { guildService } = + await import('../../../src/services/GuildService') + ;(guildService.getFullGuildRoles as jest.Mock).mockResolvedValue([]) + ;(guildService.createGuildRole as jest.Mock).mockResolvedValue({ + id: ROLE_ID_A, + name: 'NewRole', + color: 5793266, + hoist: true, + mentionable: false, + permissions: '0', + position: 0, + managed: false, + }) + + const { reactionRolesService } = + await import('@lucky/shared/services/ReactionRolesService') + ;( + reactionRolesService.addRoleToMessage as jest.Mock + ).mockResolvedValue({ + status: 'partial_success', + mapping: { + id: 'map-1', + roleId: ROLE_ID_A, + label: 'NewRole', + style: 'Primary', + }, + }) + + const { infoLog } = await import('@lucky/shared/utils/general/log') + + const result = await service.addRoleToGroup( + GUILD_ID, + 'group-1', + { name: 'NewRole' }, + BOT_TOKEN, + ) + + expect(result.status).toBe('partial_success') + expect(result.role?.id).toBe(ROLE_ID_A) + // Audit log should be called + expect(infoLog).toHaveBeenCalled() + }) + + test('respects colorOverride when provided', async () => { + mockPrisma.roleGroup.findFirst.mockResolvedValue({ + id: 'group-1', + guildId: GUILD_ID, + color: '0x5865F2', + hoist: true, + mentionable: false, + buttonStyle: 'Primary', + }) + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValue({ + id: MESSAGE_ID, + guildId: GUILD_ID, + }) + mockPrisma.reactionRoleMapping.findMany.mockResolvedValue([]) + + const { guildService } = + await import('../../../src/services/GuildService') + ;(guildService.getFullGuildRoles as jest.Mock).mockResolvedValue([]) + ;(guildService.createGuildRole as jest.Mock).mockResolvedValue({ + id: ROLE_ID_A, + name: 'NewRole', + color: 16711680, // red + hoist: true, + mentionable: false, + permissions: '0', + position: 0, + managed: false, + }) + + const { reactionRolesService } = + await import('@lucky/shared/services/ReactionRolesService') + ;( + reactionRolesService.addRoleToMessage as jest.Mock + ).mockResolvedValue({ + status: 'ok', + mapping: { + id: 'map-1', + roleId: ROLE_ID_A, + label: 'NewRole', + style: 'Primary', + }, + }) + + await service.addRoleToGroup( + GUILD_ID, + 'group-1', + { name: 'NewRole', colorOverride: '0xFF0000' }, + BOT_TOKEN, + ) + + expect(guildService.createGuildRole).toHaveBeenCalledWith( + GUILD_ID, + expect.objectContaining({ + color: 16711680, + }), + ) + }) + + test('rejects addRoleToGroup when group belongs to different guild (IDOR)', async () => { + mockPrisma.roleGroup.findFirst.mockResolvedValue(null) + + await expect( + service.addRoleToGroup( + GUILD_ID, + 'group-other-guild', + { name: 'NewRole' }, + BOT_TOKEN, + ), + ).rejects.toThrow(/not found/) + + // Verify findFirst was called with both id AND guildId filter + expect(mockPrisma.roleGroup.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'group-other-guild', guildId: GUILD_ID }, + }), + ) + }) + }) +}) diff --git a/packages/frontend/src/components/reactionRoles/AddStyledRoleForm.test.tsx b/packages/frontend/src/components/reactionRoles/AddStyledRoleForm.test.tsx new file mode 100644 index 000000000..df9207f02 --- /dev/null +++ b/packages/frontend/src/components/reactionRoles/AddStyledRoleForm.test.tsx @@ -0,0 +1,491 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +vi.mock('@/services/api', () => { + return { + api: { + roleGroups: { + addRole: vi.fn(), + }, + }, + } +}) + +import { AddStyledRoleForm } from './AddStyledRoleForm' +import { api } from '@/services/api' + +describe('AddStyledRoleForm', () => { + const mockGroupId = 'group-123' + const mockGuildId = 'guild-123' + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + test('renders form with all fields', () => { + render( + , + ) + + expect(screen.getByText(/add styled role/i)).toBeInTheDocument() + expect(screen.getByText(/role name/i)).toBeInTheDocument() + expect(screen.getByText(/button label/i)).toBeInTheDocument() + expect(screen.getByText(/color override/i)).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /preview/i }), + ).toBeInTheDocument() + }) + + test('preview button is disabled when name is empty', () => { + render( + , + ) + + const previewButton = screen.getByRole('button', { + name: /preview/i, + }) + expect(previewButton).toBeDisabled() + }) + }) + + describe('dryRun preview flow', () => { + test('clicking preview calls addRole with dryRun:true', async () => { + const user = userEvent.setup() + const mockPlan = { + plan: { + roleName: 'Moderator', + color: '0xFF0000', + buttonLabel: 'Moderator', + emoji: undefined, + }, + } + vi.mocked(api.roleGroups.addRole).mockResolvedValueOnce(mockPlan) + + render( + , + ) + + const nameInput = screen.getByPlaceholderText(/e\.g\. moderator/i) + await user.type(nameInput, 'Moderator') + + const previewButton = screen.getByRole('button', { + name: /preview/i, + }) + await user.click(previewButton) + + await waitFor(() => { + expect(vi.mocked(api.roleGroups.addRole)).toHaveBeenCalledWith( + mockGuildId, + mockGroupId, + expect.objectContaining({ + name: 'Moderator', + dryRun: true, + }), + ) + }) + }) + + test('preview displays returned plan', async () => { + const user = userEvent.setup() + const mockPlan = { + plan: { + roleName: 'Admin', + color: '0xFF0000', + buttonLabel: 'Admin Button', + emoji: '👑', + }, + } + vi.mocked(api.roleGroups.addRole).mockResolvedValueOnce(mockPlan) + + render( + , + ) + + const nameInput = screen.getByPlaceholderText(/e\.g\. moderator/i) + await user.type(nameInput, 'Admin') + + const previewButton = screen.getByRole('button', { + name: /preview/i, + }) + await user.click(previewButton) + + await waitFor(() => { + expect(screen.getByText(/preview/i)).toBeInTheDocument() + expect(screen.getByText('Admin Button')).toBeInTheDocument() + expect(screen.getByText('0xFF0000')).toBeInTheDocument() + expect(screen.getByText('👑')).toBeInTheDocument() + }) + }) + + test('preview shows color swatch with correct background', async () => { + const user = userEvent.setup() + const mockPlan = { + plan: { + roleName: 'Moderator', + color: '0xFF0000', + buttonLabel: 'Moderator', + emoji: undefined, + }, + } + vi.mocked(api.roleGroups.addRole).mockResolvedValueOnce(mockPlan) + + render( + , + ) + + const nameInput = screen.getByPlaceholderText(/e\.g\. moderator/i) + await user.type(nameInput, 'Moderator') + + const previewButton = screen.getByRole('button', { + name: /preview/i, + }) + await user.click(previewButton) + + await waitFor(() => { + const swatch = screen.getByTestId('color-swatch') + expect(swatch).toBeInTheDocument() + // Check that it has a style attribute (background color will be inlined) + const style = window.getComputedStyle(swatch) + expect(style.backgroundColor).toBeTruthy() + }) + }) + }) + + describe('double-submit guard', () => { + test('preview button enabled when name is provided', async () => { + const user = userEvent.setup() + + render( + , + ) + + const nameInput = screen.getByPlaceholderText(/e\.g\. moderator/i) + const previewButton = screen.getByRole('button', { + name: /preview/i, + }) + + // Initially disabled + expect(previewButton).toBeDisabled() + + // After typing, enabled + await user.type(nameInput, 'Moderator') + expect(previewButton).not.toBeDisabled() + + // Clearing makes it disabled again + await user.clear(nameInput) + expect(previewButton).toBeDisabled() + }) + }) + + describe('confirm to apply', () => { + test('clicking Confirm applies with dryRun:false', async () => { + const user = userEvent.setup() + const mockPlan = { + plan: { + roleName: 'Moderator', + color: '0xFF0000', + buttonLabel: 'Moderator', + emoji: undefined, + }, + } + const mockAppliedResult = { + status: 'ok' as const, + role: { + id: 'role-123', + name: 'Moderator', + color: '0xFF0000', + }, + mapping: { + id: 'mapping-1', + roleId: 'role-123', + label: 'Moderator', + emoji: undefined, + }, + } + + vi.mocked(api.roleGroups.addRole) + .mockResolvedValueOnce(mockPlan) + .mockResolvedValueOnce(mockAppliedResult) + + const onSuccess = vi.fn() + + render( + , + ) + + const nameInput = screen.getByPlaceholderText(/e\.g\. moderator/i) + await user.type(nameInput, 'Moderator') + + const previewButton = screen.getByRole('button', { + name: /preview/i, + }) + await user.click(previewButton) + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /confirm/i }), + ).toBeInTheDocument() + }) + + const confirmButton = screen.getByRole('button', { + name: /confirm/i, + }) + await user.click(confirmButton) + + await waitFor(() => { + expect( + vi.mocked(api.roleGroups.addRole), + ).toHaveBeenLastCalledWith( + mockGuildId, + mockGroupId, + expect.objectContaining({ + name: 'Moderator', + dryRun: false, + }), + ) + }) + + expect(onSuccess).toHaveBeenCalled() + }) + + test('successful apply calls onSuccess', async () => { + const user = userEvent.setup() + const mockPlan = { + plan: { + roleName: 'Moderator', + color: '0xFF0000', + buttonLabel: 'Moderator', + emoji: undefined, + }, + } + const mockAppliedResult = { + status: 'ok' as const, + role: { + id: 'role-123', + name: 'Moderator', + color: '0xFF0000', + }, + mapping: { + id: 'mapping-1', + roleId: 'role-123', + label: 'Moderator', + emoji: undefined, + }, + } + + vi.mocked(api.roleGroups.addRole) + .mockResolvedValueOnce(mockPlan) + .mockResolvedValueOnce(mockAppliedResult) + + const onSuccess = vi.fn() + + render( + , + ) + + const nameInput = screen.getByPlaceholderText(/e\.g\. moderator/i) + await user.type(nameInput, 'Moderator') + + const previewButton = screen.getByRole('button', { + name: /preview/i, + }) + await user.click(previewButton) + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /confirm/i }), + ).toBeInTheDocument() + }) + + const confirmButton = screen.getByRole('button', { + name: /confirm/i, + }) + await user.click(confirmButton) + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalled() + }) + }) + }) + + describe('partial_success state', () => { + test('shows distinct message when partial_success', async () => { + const user = userEvent.setup() + const mockPlan = { + plan: { + roleName: 'Moderator', + color: '0xFF0000', + buttonLabel: 'Moderator', + emoji: undefined, + }, + } + const mockPartialResult = { + status: 'partial_success' as const, + role: { + id: 'role-123', + name: 'Moderator', + color: '0xFF0000', + }, + mapping: { + id: 'mapping-1', + roleId: 'role-123', + label: 'Moderator', + emoji: undefined, + }, + } + + vi.mocked(api.roleGroups.addRole) + .mockResolvedValueOnce(mockPlan) + .mockResolvedValueOnce(mockPartialResult) + + const onSuccess = vi.fn() + + render( + , + ) + + const nameInput = screen.getByPlaceholderText(/e\.g\. moderator/i) + await user.type(nameInput, 'Moderator') + + const previewButton = screen.getByRole('button', { + name: /preview/i, + }) + await user.click(previewButton) + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /confirm/i }), + ).toBeInTheDocument() + }) + + const confirmButton = screen.getByRole('button', { + name: /confirm/i, + }) + await user.click(confirmButton) + + await waitFor(() => { + expect( + screen.getByText(/will re-sync shortly/i), + ).toBeInTheDocument() + }) + + expect(onSuccess).toHaveBeenCalled() + }) + }) + + describe('error handling', () => { + test('displays error message when preview fails', async () => { + const user = userEvent.setup() + const errorMsg = 'Role already mapped to this group' + vi.mocked(api.roleGroups.addRole).mockRejectedValueOnce( + new Error(errorMsg), + ) + + render( + , + ) + + const nameInput = screen.getByPlaceholderText(/e\.g\. moderator/i) + await user.type(nameInput, 'Moderator') + + const previewButton = screen.getByRole('button', { + name: /preview/i, + }) + await user.click(previewButton) + + await waitFor(() => { + expect(screen.getByText(errorMsg)).toBeInTheDocument() + }) + }) + + test('displays error message when apply fails', async () => { + const user = userEvent.setup() + const mockPlan = { + plan: { + roleName: 'Moderator', + color: '0xFF0000', + buttonLabel: 'Moderator', + emoji: undefined, + }, + } + const applyErrorMsg = 'Discord rate limit exceeded' + + vi.mocked(api.roleGroups.addRole) + .mockResolvedValueOnce(mockPlan) + .mockRejectedValueOnce(new Error(applyErrorMsg)) + + render( + , + ) + + const nameInput = screen.getByPlaceholderText(/e\.g\. moderator/i) + await user.type(nameInput, 'Moderator') + + const previewButton = screen.getByRole('button', { + name: /preview/i, + }) + await user.click(previewButton) + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /confirm/i }), + ).toBeInTheDocument() + }) + + const confirmButton = screen.getByRole('button', { + name: /confirm/i, + }) + await user.click(confirmButton) + + await waitFor(() => { + expect(screen.getByText(applyErrorMsg)).toBeInTheDocument() + }) + }) + }) +}) diff --git a/packages/frontend/src/components/reactionRoles/AddStyledRoleForm.tsx b/packages/frontend/src/components/reactionRoles/AddStyledRoleForm.tsx new file mode 100644 index 000000000..a1b56687b --- /dev/null +++ b/packages/frontend/src/components/reactionRoles/AddStyledRoleForm.tsx @@ -0,0 +1,336 @@ +import { useState } from 'react' +import { Wand2, X } from 'lucide-react' +import Button from '@/components/ui/Button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import Card from '@/components/ui/Card' +import EmojiPicker from '@/components/ui/EmojiPicker' +import { api } from '@/services/api' +import type { + AddRoleDryRunResult, + AddRoleAppliedResult, +} from '@/services/roleGroupsApi' + +interface AddStyledRoleFormProps { + guildId: string + groupId: string + onSuccess: () => void +} + +type Preview = AddRoleDryRunResult | AddRoleAppliedResult | null + +export function AddStyledRoleForm({ + guildId, + groupId, + onSuccess, +}: AddStyledRoleFormProps) { + const [name, setName] = useState('') + const [label, setLabel] = useState('') + const [emoji, setEmoji] = useState('') + const [colorOverride, setColorOverride] = useState('') + const [preview, setPreview] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [confirming, setConfirming] = useState(false) + + async function handlePreview() { + setError(null) + setLoading(true) + try { + const result = await api.roleGroups.addRole(guildId, groupId, { + name: name.trim(), + label: label.trim() || name.trim(), + emoji: emoji.trim() || undefined, + colorOverride: colorOverride.trim() || undefined, + dryRun: true, + }) + setPreview(result) + } catch (err) { + const msg = + err instanceof Error + ? err.message + : 'Failed to generate preview' + setError(msg) + } finally { + setLoading(false) + } + } + + async function handleConfirm() { + if (!preview) return + setError(null) + setConfirming(true) + try { + const result = await api.roleGroups.addRole(guildId, groupId, { + name: name.trim(), + label: label.trim() || name.trim(), + emoji: emoji.trim() || undefined, + colorOverride: colorOverride.trim() || undefined, + dryRun: false, + }) + setPreview(result) + // Don't reset form if partial_success; let user see the outcome + if ('status' in result && result.status === 'ok') { + setName('') + setLabel('') + setEmoji('') + setColorOverride('') + setPreview(null) + } + onSuccess() + } catch (err) { + const msg = + err instanceof Error ? err.message : 'Failed to add role' + setError(msg) + } finally { + setConfirming(false) + } + } + + function handleReset() { + setName('') + setLabel('') + setEmoji('') + setColorOverride('') + setPreview(null) + setError(null) + } + + const isDryRun = preview && 'plan' in preview + const isApplied = preview && 'status' in preview + const isPartialSuccess = isApplied && preview.status === 'partial_success' + + return ( +
+ {!preview ? ( + +

+ Add Styled Role +

+ + {error && ( +
+

+ {error} +

+
+ )} + +
+
+ + setName(e.target.value)} + placeholder='e.g. Moderator' + maxLength={100} + disabled={loading} + /> +
+ +
+ + setLabel(e.target.value)} + placeholder='Defaults to role name' + maxLength={80} + disabled={loading} + /> +
+ +
+ + +
+ +
+ + + setColorOverride(e.target.value) + } + placeholder='e.g. 0xFF0000' + maxLength={8} + disabled={loading} + /> + {colorOverride && ( +
+
+ + {colorOverride} + +
+ )} +
+
+ + + + ) : isDryRun ? ( + +
+

+ Preview +

+ +
+ + {error && ( +
+

+ {error} +

+
+ )} + +
+
+

+ Role Name +

+

+ {preview.plan.roleName} +

+
+ +
+

+ Button Label +

+
+ {preview.plan.emoji && ( + + {preview.plan.emoji} + + )} +

+ {preview.plan.buttonLabel} +

+
+
+ +
+

+ Color +

+
+
+ + {preview.plan.color} + +
+
+
+ +
+ + +
+ + ) : isApplied ? ( + +

+ Role Added +

+ + {error && ( +
+

+ {error} +

+
+ )} + +
+
+

+ Role +

+

+ {preview.role.name} +

+
+
+

+ Button +

+
+ {preview.mapping.emoji && ( + + {preview.mapping.emoji} + + )} +

+ {preview.mapping.label} +

+
+
+
+ + {isPartialSuccess && ( +
+

+ Role added successfully. The Discord message + will re-sync shortly. +

+
+ )} + + +
+ ) : null} +
+ ) +} diff --git a/packages/frontend/src/locales/en.json b/packages/frontend/src/locales/en.json index 127625a4b..77fd70322 100644 --- a/packages/frontend/src/locales/en.json +++ b/packages/frontend/src/locales/en.json @@ -828,7 +828,12 @@ "updateFailedError": "Failed to update reaction role message", "deleteFailedError": "Failed to delete reaction role message.", "loadFailedError": "Failed to load reaction role messages.", - "noMappings": "No role mappings found for this message." + "noMappings": "No role mappings found for this message.", + "createRoleGroup": "Create role group", + "createRoleGroupTitle": "Create a role group to manage styled roles", + "addStyledRole": "Add styled role", + "defaultRoleGroupName": "Role Group {{timestamp}}", + "createRoleGroupError": "Failed to create role group." }, "serverLogs": { "title": "Server Logs", diff --git a/packages/frontend/src/locales/pt-BR.json b/packages/frontend/src/locales/pt-BR.json index 45a080588..b9a030df9 100644 --- a/packages/frontend/src/locales/pt-BR.json +++ b/packages/frontend/src/locales/pt-BR.json @@ -824,7 +824,12 @@ "updateFailedError": "Falha ao atualizar mensagem de cargo por reação", "deleteFailedError": "Falha ao excluir mensagem de cargo por reação.", "loadFailedError": "Falha ao carregamentar mensagens de cargo por reação.", - "noMappings": "Nenhum mapeamento de cargo encontrado para esta mensagem." + "noMappings": "Nenhum mapeamento de cargo encontrado para esta mensagem.", + "createRoleGroup": "Criar grupo de cargos", + "createRoleGroupTitle": "Crie um grupo de cargos para gerenciar cargos estilizados", + "addStyledRole": "Adicionar cargo estilizado", + "defaultRoleGroupName": "Grupo de Cargos {{timestamp}}", + "createRoleGroupError": "Falha ao criar o grupo de cargos." }, "serverLogs": { "title": "Logs do Servidor", diff --git a/packages/frontend/src/pages/ReactionRoles.test.tsx b/packages/frontend/src/pages/ReactionRoles.test.tsx index f47edbe03..94e7195fa 100644 --- a/packages/frontend/src/pages/ReactionRoles.test.tsx +++ b/packages/frontend/src/pages/ReactionRoles.test.tsx @@ -2267,3 +2267,206 @@ test('submit with empty roles shows error', async () => { expect(errorMessages.length).toBeGreaterThan(0) }) }) + +describe('Role Groups Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test('displays "Create role group" button for messages without groupId', async () => { + mockGuildStore() + const messages: ReactionRoleMessage[] = [ + { + id: '1', + guildId: '123456', + messageId: 'msg-123', + channelId: 'channel-456', + groupId: undefined, + createdAt: new Date('2024-01-15').toISOString(), + mappings: [ + { + id: 'map-1', + buttonId: '', + type: 'button', + emoji: '🎮', + label: 'Gamer', + style: 'Primary', + roleId: 'role-111', + }, + ], + }, + ] + vi.mocked(api.reactionRoles.list).mockResolvedValue(messages) + + render() + + await waitFor(() => { + expect(api.reactionRoles.list).toHaveBeenCalled() + }) + + // Should have a button to create a role group + const createGroupButton = await screen.findByRole('button', { + name: /create role group/i, + }) + expect(createGroupButton).toBeInTheDocument() + }) + + test('calls api.roleGroups.create with correct arguments when "Create role group" is clicked', async () => { + mockGuildStore() + const messages: ReactionRoleMessage[] = [ + { + id: 'cuid-msg-1', + guildId: '123456', + messageId: 'msg-123', + channelId: 'channel-456', + groupId: undefined, + createdAt: new Date('2024-01-15').toISOString(), + mappings: [ + { + id: 'map-1', + buttonId: '', + type: 'button', + emoji: '🎮', + label: 'Gamer', + style: 'Primary', + roleId: 'role-111', + }, + ], + }, + ] + vi.mocked(api.reactionRoles.list).mockResolvedValue(messages) + vi.mocked(api.roleGroups.create).mockResolvedValue({ + id: 'group-1', + guildId: '123456', + name: 'Test Group', + hoist: false, + mentionable: false, + } as any) + + render() + + await waitFor(() => { + expect(api.reactionRoles.list).toHaveBeenCalled() + }) + + const createGroupButton = await screen.findByRole('button', { + name: /create role group/i, + }) + fireEvent.click(createGroupButton) + + await waitFor(() => { + expect(api.roleGroups.create).toHaveBeenCalledWith( + '123456', + expect.objectContaining({ + fromMessageId: 'cuid-msg-1', + name: expect.any(String), + }), + ) + }) + }) + + test('refetches messages after creating a role group', async () => { + mockGuildStore() + const messages: ReactionRoleMessage[] = [ + { + id: 'cuid-msg-1', + guildId: '123456', + messageId: 'msg-123', + channelId: 'channel-456', + groupId: undefined, + createdAt: new Date('2024-01-15').toISOString(), + mappings: [ + { + id: 'map-1', + buttonId: '', + type: 'button', + emoji: '🎮', + label: 'Gamer', + style: 'Primary', + roleId: 'role-111', + }, + ], + }, + ] + const updatedMessages: ReactionRoleMessage[] = [ + { + ...messages[0], + groupId: 'group-1', + }, + ] + + vi.mocked(api.reactionRoles.list) + .mockResolvedValueOnce(messages) + .mockResolvedValueOnce(updatedMessages) + vi.mocked(api.roleGroups.create).mockResolvedValue({ + id: 'group-1', + guildId: '123456', + name: 'Test Group', + hoist: false, + mentionable: false, + } as any) + + render() + + await waitFor(() => { + expect(api.reactionRoles.list).toHaveBeenCalledTimes(1) + }) + + const createGroupButton = await screen.findByRole('button', { + name: /create role group/i, + }) + fireEvent.click(createGroupButton) + + await waitFor(() => { + expect(api.reactionRoles.list).toHaveBeenCalledTimes(2) + }) + }) + + test('renders AddStyledRoleForm when message has groupId', async () => { + mockGuildStore() + const messages: ReactionRoleMessage[] = [ + { + id: '1', + guildId: '123456', + messageId: 'msg-123', + channelId: 'channel-456', + groupId: 'group-1', + createdAt: new Date('2024-01-15').toISOString(), + mappings: [ + { + id: 'map-1', + buttonId: '', + type: 'button', + emoji: '🎮', + label: 'Gamer', + style: 'Primary', + roleId: 'role-111', + }, + ], + }, + ] + vi.mocked(api.reactionRoles.list).mockResolvedValue(messages) + + render() + + await waitFor(() => { + expect(api.reactionRoles.list).toHaveBeenCalled() + }) + + // A grouped message shows the "Add styled role" toggle (and NOT the + // "Create role group" button, which is only for ungrouped messages). + const toggle = await screen.findByRole('button', { + name: /add styled role/i, + }) + expect( + screen.queryByRole('button', { name: /create role group/i }), + ).not.toBeInTheDocument() + + // Clicking it mounts the AddStyledRoleForm. + fireEvent.click(toggle) + expect( + await screen.findByRole('button', { name: /preview/i }), + ).toBeInTheDocument() + expect(screen.getByText(/role name/i)).toBeInTheDocument() + }) +}) diff --git a/packages/frontend/src/pages/ReactionRoles.tsx b/packages/frontend/src/pages/ReactionRoles.tsx index 896a5e1c7..1db19bc9f 100644 --- a/packages/frontend/src/pages/ReactionRoles.tsx +++ b/packages/frontend/src/pages/ReactionRoles.tsx @@ -19,6 +19,7 @@ import EmptyState from '@/components/ui/EmptyState' import Skeleton from '@/components/ui/Skeleton' import SectionHeader from '@/components/ui/SectionHeader' import { Badge } from '@/components/ui/badge' +import { AddStyledRoleForm } from '@/components/reactionRoles/AddStyledRoleForm' import { Dialog, DialogContent, @@ -114,15 +115,23 @@ function MappingPill({ function MessageCard({ message, + guildId, onDelete, onEdit, + onCreateRoleGroup, + onRoleAdded, }: { message: ReactionRoleMessage + guildId: string onDelete: (messageId: string) => Promise onEdit: (message: ReactionRoleMessage) => void + onCreateRoleGroup: (messageId: string) => Promise + onRoleAdded: () => void }) { const { t } = useTranslation() const [deleting, setDeleting] = useState(false) + const [creatingGroup, setCreatingGroup] = useState(false) + const [showAddRole, setShowAddRole] = useState(false) const date = new Date(message.createdAt) const dateStr = date.toLocaleDateString(undefined, { year: 'numeric', @@ -139,6 +148,15 @@ function MessageCard({ } } + async function handleCreateRoleGroup() { + setCreatingGroup(true) + try { + await onCreateRoleGroup(message.id) + } finally { + setCreatingGroup(false) + } + } + return (
@@ -165,6 +183,18 @@ function MessageCard({ {message.mappings.length}{' '} {message.mappings.length === 1 ? 'role' : 'roles'} + {!message.groupId && ( + + )} + ) : ( +
+ + +
+ )} +
+ )}
) } @@ -858,6 +918,26 @@ export default function ReactionRoles() { void fetchMessages() } + async function handleCreateRoleGroup(messageId: string) { + if (!selectedGuild) return + setDeleteError(null) + try { + await api.roleGroups.create(selectedGuild.id, { + name: t('reactionRoles.defaultRoleGroupName', { + timestamp: new Date().toLocaleString(), + }), + fromMessageId: messageId, + }) + void fetchMessages() + } catch (err) { + setDeleteError( + err instanceof Error + ? err.message + : t('reactionRoles.createRoleGroupError'), + ) + } + } + function handleExport() { const exported = serializeReactionRolesToJSON(messages) const jsonString = JSON.stringify(exported, null, 2) @@ -962,8 +1042,11 @@ export default function ReactionRoles() { > void fetchMessages()} /> ))} diff --git a/packages/frontend/src/services/api.ts b/packages/frontend/src/services/api.ts index 05628ec37..0f6b9722b 100644 --- a/packages/frontend/src/services/api.ts +++ b/packages/frontend/src/services/api.ts @@ -31,6 +31,7 @@ import { createArtistsApi } from './artistsApi' import { createSupportApi } from './supportApi' import { inferApiBase } from './apiBase' import { createRolesManageApi } from './rolesManageApi' +import { createRoleGroupsApi } from './roleGroupsApi' const browserLocation = typeof globalThis !== 'undefined' && 'window' in globalThis @@ -430,6 +431,7 @@ export const api = { autoMessages: createAutoMessagesApi(apiClient), embeds: createEmbedsApi(apiClient), reactionRoles: createReactionRolesApi(apiClient), + roleGroups: createRoleGroupsApi(apiClient), automation: createAutomationApi(apiClient), levels: createLevelsApi(apiClient), starboard: createStarboardApi(apiClient), diff --git a/packages/frontend/src/services/reactionRolesApi.ts b/packages/frontend/src/services/reactionRolesApi.ts index f6689d121..501327b49 100644 --- a/packages/frontend/src/services/reactionRolesApi.ts +++ b/packages/frontend/src/services/reactionRolesApi.ts @@ -15,6 +15,7 @@ export interface ReactionRoleMessage { messageId: string channelId: string guildId: string + groupId?: string | null title?: string description?: string imageUrl?: string diff --git a/packages/frontend/src/services/roleGroupsApi.test.ts b/packages/frontend/src/services/roleGroupsApi.test.ts new file mode 100644 index 000000000..2f0ca04e1 --- /dev/null +++ b/packages/frontend/src/services/roleGroupsApi.test.ts @@ -0,0 +1,221 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest' +import type { AxiosInstance } from 'axios' +import { createRoleGroupsApi } from './roleGroupsApi' + +describe('roleGroupsApi', () => { + let mockClient: AxiosInstance + let api: ReturnType + + beforeEach(() => { + mockClient = { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as AxiosInstance + api = createRoleGroupsApi(mockClient) + }) + + describe('list', () => { + test('fetches role groups for a guild', async () => { + const guildId = 'guild-123' + const mockGroups = [ + { + id: 'group-1', + guildId, + name: 'Main Roles', + color: '0x5865F2', + hoist: false, + mentionable: false, + buttonStyle: 'Primary', + defaultEmoji: null, + }, + ] + vi.mocked(mockClient.get).mockResolvedValueOnce({ + data: { groups: mockGroups }, + }) + + const result = await api.list(guildId) + + expect(mockClient.get).toHaveBeenCalledWith( + `/guilds/${guildId}/role-groups`, + ) + expect(result).toEqual(mockGroups) + }) + }) + + describe('create', () => { + test('creates a role group from a message', async () => { + const guildId = 'guild-123' + const payload = { + name: 'Styled Roles', + fromMessageId: 'msg-456', + } + const mockResult = { + id: 'group-1', + guildId, + ...payload, + color: '0x5865F2', + hoist: false, + mentionable: false, + buttonStyle: 'Primary', + defaultEmoji: null, + } + vi.mocked(mockClient.post).mockResolvedValueOnce({ + data: mockResult, + }) + + const result = await api.create(guildId, payload) + + expect(mockClient.post).toHaveBeenCalledWith( + `/guilds/${guildId}/role-groups`, + payload, + ) + expect(result).toEqual(mockResult) + }) + }) + + describe('get', () => { + test('fetches a single role group', async () => { + const guildId = 'guild-123' + const groupId = 'group-1' + const mockGroup = { + id: groupId, + guildId, + name: 'Main Roles', + color: '0x5865F2', + hoist: false, + mentionable: false, + buttonStyle: 'Primary', + defaultEmoji: null, + } + vi.mocked(mockClient.get).mockResolvedValueOnce({ + data: mockGroup, + }) + + const result = await api.get(guildId, groupId) + + expect(mockClient.get).toHaveBeenCalledWith( + `/guilds/${guildId}/role-groups/${groupId}`, + ) + expect(result).toEqual(mockGroup) + }) + }) + + describe('updateTemplate', () => { + test('patches a role group template', async () => { + const guildId = 'guild-123' + const groupId = 'group-1' + const payload = { + color: '0xFF0000', + hoist: true, + } + const mockResult = { + id: groupId, + guildId, + name: 'Main Roles', + ...payload, + mentionable: false, + buttonStyle: 'Primary', + defaultEmoji: null, + } + vi.mocked(mockClient.patch).mockResolvedValueOnce({ + data: mockResult, + }) + + const result = await api.updateTemplate(guildId, groupId, payload) + + expect(mockClient.patch).toHaveBeenCalledWith( + `/guilds/${guildId}/role-groups/${groupId}`, + payload, + ) + expect(result).toEqual(mockResult) + }) + }) + + describe('addRole', () => { + test('calls addRole with dryRun=true and returns plan', async () => { + const guildId = 'guild-123' + const groupId = 'group-1' + const payload = { + name: 'New Role', + label: 'New Role', + emoji: '🎮', + dryRun: true, + } + const mockPlan = { + plan: { + roleName: 'New Role', + color: '0x5865F2', + buttonLabel: 'New Role', + emoji: '🎮', + }, + } + vi.mocked(mockClient.post).mockResolvedValueOnce({ + data: mockPlan, + }) + + const result = await api.addRole(guildId, groupId, payload) + + expect(mockClient.post).toHaveBeenCalledWith( + `/guilds/${guildId}/role-groups/${groupId}/roles`, + payload, + ) + expect(result).toEqual(mockPlan) + }) + + test('calls addRole with dryRun=false and returns applied result', async () => { + const guildId = 'guild-123' + const groupId = 'group-1' + const payload = { + name: 'New Role', + label: 'New Role', + emoji: '🎮', + dryRun: false, + } + const mockResult = { + status: 'ok', + role: { + id: 'role-new', + name: 'New Role', + color: '0x5865F2', + }, + mapping: { + id: 'mapping-1', + roleId: 'role-new', + label: 'New Role', + emoji: '🎮', + }, + } + vi.mocked(mockClient.post).mockResolvedValueOnce({ + data: mockResult, + }) + + const result = await api.addRole(guildId, groupId, payload) + + expect(mockClient.post).toHaveBeenCalledWith( + `/guilds/${guildId}/role-groups/${groupId}/roles`, + payload, + ) + expect(result).toEqual(mockResult) + }) + }) + + describe('detachRole', () => { + test('deletes a role from a group', async () => { + const guildId = 'guild-123' + const groupId = 'group-1' + const roleId = 'role-456' + vi.mocked(mockClient.delete).mockResolvedValueOnce({ + data: { success: true }, + }) + + const result = await api.detachRole(guildId, groupId, roleId) + + expect(mockClient.delete).toHaveBeenCalledWith( + `/guilds/${guildId}/role-groups/${groupId}/roles/${roleId}`, + ) + expect(result).toBe(true) + }) + }) +}) diff --git a/packages/frontend/src/services/roleGroupsApi.ts b/packages/frontend/src/services/roleGroupsApi.ts new file mode 100644 index 000000000..a14b907ac --- /dev/null +++ b/packages/frontend/src/services/roleGroupsApi.ts @@ -0,0 +1,121 @@ +import type { AxiosInstance } from 'axios' + +export interface RoleGroup { + id: string + guildId: string + name: string + color?: string + hoist: boolean + mentionable: boolean + buttonStyle?: string + defaultEmoji?: string | null +} + +export interface CreateRoleGroupPayload { + name: string + fromMessageId?: string + style?: Record +} + +export interface UpdateRoleGroupPayload { + color?: string + hoist?: boolean + mentionable?: boolean + buttonStyle?: string + defaultEmoji?: string | null +} + +export interface AddRolePayload { + name: string + label?: string + emoji?: string + colorOverride?: string + dryRun?: boolean +} + +export interface AddRoleDryRunResult { + plan: { + roleName: string + color: string + buttonLabel: string + emoji?: string + } +} + +export interface AddRoleAppliedResult { + status: 'ok' | 'partial_success' + role: { + id: string + name: string + color: string + } + mapping: { + id: string + roleId: string + label: string + emoji?: string | null + } +} + +export function createRoleGroupsApi(client: AxiosInstance) { + return { + list: async (guildId: string): Promise => { + const res = await client.get<{ groups: RoleGroup[] }>( + `/guilds/${guildId}/role-groups`, + ) + return res.data.groups + }, + + create: async ( + guildId: string, + payload: CreateRoleGroupPayload, + ): Promise => { + const res = await client.post( + `/guilds/${guildId}/role-groups`, + payload, + ) + return res.data + }, + + get: async (guildId: string, groupId: string): Promise => { + const res = await client.get( + `/guilds/${guildId}/role-groups/${groupId}`, + ) + return res.data + }, + + updateTemplate: async ( + guildId: string, + groupId: string, + payload: UpdateRoleGroupPayload, + ): Promise => { + const res = await client.patch( + `/guilds/${guildId}/role-groups/${groupId}`, + payload, + ) + return res.data + }, + + addRole: async ( + guildId: string, + groupId: string, + payload: AddRolePayload, + ): Promise => { + const res = await client.post< + AddRoleDryRunResult | AddRoleAppliedResult + >(`/guilds/${guildId}/role-groups/${groupId}/roles`, payload) + return res.data + }, + + detachRole: async ( + guildId: string, + groupId: string, + roleId: string, + ): Promise => { + const res = await client.delete<{ success: boolean }>( + `/guilds/${guildId}/role-groups/${groupId}/roles/${roleId}`, + ) + return res.data.success + }, + } +} diff --git a/packages/shared/package.json b/packages/shared/package.json index f74ae61e3..0f3d14742 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -26,6 +26,10 @@ "types": "./dist/services/guildAutomation/index.d.ts", "default": "./dist/services/guildAutomation/index.js" }, + "./services/ReactionRolesService": { + "types": "./dist/services/ReactionRolesService/index.d.ts", + "default": "./dist/services/ReactionRolesService/index.js" + }, "./services/*": { "types": "./dist/services/*.d.ts", "default": "./dist/services/*.js" diff --git a/packages/shared/src/services/ReactionRolesService/index.spec.ts b/packages/shared/src/services/ReactionRolesService/index.spec.ts index 07fa5320c..27e27b67e 100644 --- a/packages/shared/src/services/ReactionRolesService/index.spec.ts +++ b/packages/shared/src/services/ReactionRolesService/index.spec.ts @@ -1819,4 +1819,346 @@ describe('ReactionRolesService', () => { expect(payload.attachments).toEqual([]) }) }) + + describe('addRoleToMessage (DB-first append)', () => { + const messageId = '98765432109876543' + const guildId = '12345678901234567' + const channelId = '11111111111111111' + const newRoleId = '22222222222222222' + const botToken = 'token-abc' + + beforeEach(() => { + ;(global as any).fetch = jest.fn() + mockPrisma.$transaction = jest.fn() + mockPrisma.reactionRoleMessage = { + findUnique: jest.fn(), + } + mockPrisma.reactionRoleMapping = { + create: jest.fn(), + deleteMany: jest.fn(), + } + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('happy path: inserts one mapping then PATCHes Discord', async () => { + const existingMapping = { + id: 'mapping-1', + messageId, + roleId: '11111111111111111', + label: 'Existing', + emoji: null, + buttonId: 'reactionrole:11111111111111111', + type: 'button', + style: 'Primary', + } + + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValueOnce({ + id: 'msg-id-1', + messageId, + guildId, + channelId, + title: 'Test', + description: 'Click', + imageUrl: null, + mappings: [existingMapping], + }) + + mockPrisma.$transaction.mockImplementationOnce( + async (callback: any) => { + return typeof callback === 'function' + ? callback(mockPrisma) + : {} + }, + ) + + mockPrisma.reactionRoleMapping.create.mockResolvedValueOnce({ + id: 'mapping-2', + messageId, + roleId: newRoleId, + label: 'New', + emoji: null, + buttonId: `reactionrole:${newRoleId}`, + type: 'button', + style: 'Secondary', + }) + ;(global as any).fetch.mockResolvedValueOnce({ ok: true }) + + const result = await service.addRoleToMessage( + messageId, + { + roleId: newRoleId, + label: 'New', + emoji: null, + style: 'Secondary' as const, + }, + botToken, + ) + + expect(result.status).toBe('ok') + expect(result.mapping.roleId).toBe(newRoleId) + }) + + it('DB insert BEFORE Discord PATCH', async () => { + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValueOnce({ + id: 'msg-id-1', + messageId, + guildId, + channelId, + title: 'Test', + description: 'Click', + imageUrl: null, + mappings: [ + { + id: 'mapping-1', + messageId, + roleId: '11111111111111111', + label: 'Ex', + emoji: null, + buttonId: 'reactionrole:11111111111111111', + type: 'button', + style: 'Primary', + }, + ], + }) + + const callOrder: string[] = [] + + mockPrisma.$transaction.mockImplementationOnce( + async (callback: any) => { + callOrder.push('tx-start') + const result = await callback(mockPrisma) + callOrder.push('tx-end') + return result + }, + ) + + mockPrisma.reactionRoleMapping.create.mockImplementationOnce( + async () => { + callOrder.push('db-insert') + return { + id: 'mapping-2', + messageId, + roleId: newRoleId, + label: 'New', + emoji: null, + buttonId: `reactionrole:${newRoleId}`, + type: 'button', + style: 'Secondary', + } + }, + ) + ;(global as any).fetch.mockImplementationOnce(async () => { + callOrder.push('discord-patch') + return { ok: true } + }) + + await service.addRoleToMessage( + messageId, + { + roleId: newRoleId, + label: 'New', + emoji: null, + style: 'Secondary' as const, + }, + botToken, + ) + + expect(callOrder.indexOf('db-insert')).toBeLessThan( + callOrder.indexOf('discord-patch'), + ) + }) + + it('Discord PATCH fails → partial_success', async () => { + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValueOnce({ + id: 'msg-id-1', + messageId, + guildId, + channelId, + title: 'Test', + description: 'Click', + imageUrl: null, + mappings: [ + { + id: 'mapping-1', + messageId, + roleId: '11111111111111111', + label: 'Ex', + emoji: null, + buttonId: 'reactionrole:11111111111111111', + type: 'button', + style: 'Primary', + }, + ], + }) + + const createdMapping = { + id: 'mapping-2', + messageId, + roleId: newRoleId, + label: 'New', + emoji: null, + buttonId: `reactionrole:${newRoleId}`, + type: 'button', + style: 'Secondary', + } + + mockPrisma.$transaction.mockImplementationOnce( + async (callback: any) => callback(mockPrisma), + ) + mockPrisma.reactionRoleMapping.create.mockResolvedValueOnce( + createdMapping, + ) + ;(global as any).fetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: jest.fn(), + } as any) + + const result = await service.addRoleToMessage( + messageId, + { + roleId: newRoleId, + label: 'New', + emoji: null, + style: 'Secondary' as const, + }, + botToken, + ) + + expect(result.status).toBe('partial_success') + expect(result.mapping).toEqual(createdMapping) + }) + + it('25 buttons → rejects', async () => { + const mappings = Array.from({ length: 25 }, (_, i) => ({ + id: `m-${i}`, + messageId, + roleId: `r-${i}`, + label: `L${i}`, + emoji: null, + buttonId: `b-${i}`, + type: 'button', + style: 'Primary', + })) + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValueOnce({ + id: 'msg-id-1', + messageId, + guildId, + channelId, + title: 'Test', + description: 'Click', + imageUrl: null, + mappings, + }) + + await expect( + service.addRoleToMessage( + messageId, + { + roleId: newRoleId, + label: 'New', + emoji: null, + style: 'Secondary' as const, + }, + botToken, + ), + ).rejects.toThrow('Message already at capacity') + expect(mockPrisma.$transaction).not.toHaveBeenCalled() + }) + + it('duplicate roleId → conflict', async () => { + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValueOnce({ + id: 'msg-id-1', + messageId, + guildId, + channelId, + title: 'Test', + description: 'Click', + imageUrl: null, + mappings: [ + { + id: 'mapping-1', + messageId, + roleId: newRoleId, + label: 'Ex', + emoji: null, + buttonId: `reactionrole:${newRoleId}`, + type: 'button', + style: 'Primary', + }, + ], + }) + + await expect( + service.addRoleToMessage( + messageId, + { + roleId: newRoleId, + label: 'New', + emoji: null, + style: 'Secondary' as const, + }, + botToken, + ), + ).rejects.toThrow('Role already mapped') + expect(mockPrisma.$transaction).not.toHaveBeenCalled() + }) + + it('NO deleteMany called', async () => { + mockPrisma.reactionRoleMessage.findUnique.mockResolvedValueOnce({ + id: 'msg-id-1', + messageId, + guildId, + channelId, + title: 'Test', + description: 'Click', + imageUrl: null, + mappings: [ + { + id: 'mapping-1', + messageId, + roleId: '11111111111111111', + label: 'Ex', + emoji: null, + buttonId: 'reactionrole:11111111111111111', + type: 'button', + style: 'Primary', + }, + ], + }) + + mockPrisma.$transaction.mockImplementationOnce( + async (callback: any) => callback(mockPrisma), + ) + mockPrisma.reactionRoleMapping.create.mockResolvedValueOnce({ + id: 'mapping-2', + messageId, + roleId: newRoleId, + label: 'New', + emoji: null, + buttonId: `reactionrole:${newRoleId}`, + type: 'button', + style: 'Secondary', + }) + ;(global as any).fetch.mockResolvedValueOnce({ ok: true }) + + await service.addRoleToMessage( + messageId, + { + roleId: newRoleId, + label: 'New', + emoji: null, + style: 'Secondary' as const, + }, + botToken, + ) + + expect( + mockPrisma.reactionRoleMapping.deleteMany, + ).not.toHaveBeenCalled() + }) + }) }) diff --git a/packages/shared/src/services/ReactionRolesService/index.ts b/packages/shared/src/services/ReactionRolesService/index.ts index a7dba8a70..d5212a869 100644 --- a/packages/shared/src/services/ReactionRolesService/index.ts +++ b/packages/shared/src/services/ReactionRolesService/index.ts @@ -697,6 +697,113 @@ export class ReactionRolesService { return true } } + + async addRoleToMessage( + messageId: string, + newMapping: { + roleId: string + label: string + emoji?: string | null + style?: 'Primary' | 'Secondary' | 'Success' | 'Danger' + }, + botToken: string, + ): Promise<{ status: 'ok' | 'partial_success'; mapping: any }> { + if (!/^\d{17,20}$/.test(newMapping.roleId)) { + throw new Error('Invalid roleId: expected a Discord snowflake ID') + } + + const prisma = getPrismaClient() + + // Load the message and its existing mappings + const message = await prisma.reactionRoleMessage.findUnique({ + where: { messageId }, + include: { mappings: true }, + }) + + if (!message) { + throw new Error('Reaction role message not found') + } + + // Guard: capacity check (max 25 buttons). Capacity is not DB-constrained, + // so highly-concurrent appends can still race past this — full + // serialization is tracked as v2 hardening (issue #1558). Duplicate + // roleIds are enforced atomically by the @@unique([messageId, roleId]) index. + if (message.mappings.length >= 25) { + throw new Error('Message already at capacity') + } + + // Guard: duplicate roleId check + if (message.mappings.some((m) => m.roleId === newMapping.roleId)) { + throw new Error('Role already mapped') + } + + // DB-first: Insert the mapping in a transaction + const createdMapping = await prisma.$transaction(async (tx) => { + return tx.reactionRoleMapping.create({ + data: { + messageId, + roleId: newMapping.roleId, + buttonId: `reactionrole:${newMapping.roleId}`, + type: 'button', + label: newMapping.label, + style: newMapping.style ?? 'Primary', + emoji: newMapping.emoji ?? null, + }, + }) + }) + + // After DB insert, PATCH Discord with the full button set (existing + new) + try { + const allMappings = [...message.mappings, createdMapping] + const actionRows = this.buildButtonRows( + allMappings.map((m) => ({ + roleId: m.roleId, + label: m.label ?? '', + emoji: m.emoji ?? undefined, + style: (m.style ?? 'Primary') as + | 'Primary' + | 'Secondary' + | 'Success' + | 'Danger', + })), + ) + + this.assertSnowflakes(message.guildId, message.channelId, messageId) + + const DISCORD_API = 'https://discord.com/api/v10' + const { headers, body } = this.buildDiscordMessageRequest({ + title: message.title ?? '', + description: message.description ?? '', + imageUrl: message.imageUrl ?? undefined, + actionRows, + botToken, + }) + + const resp = await fetch( + `${DISCORD_API}/channels/${message.channelId}/messages/${messageId}`, + { + method: 'PATCH', + headers, + body, + signal: AbortSignal.timeout(10_000), + }, + ) + + if (!resp.ok) { + return { + status: 'partial_success' as const, + mapping: createdMapping, + } + } + + return { status: 'ok' as const, mapping: createdMapping } + } catch (discordError) { + return { + status: 'partial_success' as const, + mapping: createdMapping, + } + } + } } /** Singleton instance of ReactionRolesService. */ diff --git a/prisma/migrations/20260623120000_add_role_groups/migration.sql b/prisma/migrations/20260623120000_add_role_groups/migration.sql new file mode 100644 index 000000000..664a16512 --- /dev/null +++ b/prisma/migrations/20260623120000_add_role_groups/migration.sql @@ -0,0 +1,36 @@ +-- Role Groups: a first-class entity wrapping one reaction-role message with a +-- shared, editable style template. Additive; the FK on reaction_role_messages is +-- nullable so existing rows become groupId = NULL ("ungrouped") with no backfill. +-- The unique index on groupId enforces the v1 one-message-per-group (1:1) rule +-- (NULLs are distinct in Postgres, so many ungrouped messages are allowed). + +-- CreateTable +CREATE TABLE "role_groups" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "color" TEXT, + "hoist" BOOLEAN NOT NULL DEFAULT false, + "mentionable" BOOLEAN NOT NULL DEFAULT false, + "buttonStyle" TEXT, + "defaultEmoji" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "role_groups_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "role_groups_guildId_idx" ON "role_groups"("guildId"); + +-- AlterTable +ALTER TABLE "reaction_role_messages" ADD COLUMN "groupId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "reaction_role_messages_groupId_key" ON "reaction_role_messages"("groupId"); + +-- AddForeignKey +ALTER TABLE "role_groups" ADD CONSTRAINT "role_groups_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "guilds"("discordId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reaction_role_messages" ADD CONSTRAINT "reaction_role_messages_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "role_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7f89a8ad8..38105cb00 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -95,6 +95,7 @@ model Guild { memberBirthdays MemberBirthday[] subscription GuildSubscription? reactionRoleMessages ReactionRoleMessage[] + roleGroups RoleGroup[] forumThreads GuildForumThread[] @@map("guilds") @@ -559,19 +560,22 @@ model Recommendation { // Reaction Roles model ReactionRoleMessage { - id String @id @default(cuid()) - messageId String @unique + id String @id @default(cuid()) + messageId String @unique channelId String guildId String + groupId String? title String? description String? imageUrl String? - guild Guild @relation(fields: [guildId], references: [discordId], onDelete: Cascade) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [discordId], onDelete: Cascade) + group RoleGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt mappings ReactionRoleMapping[] + @@unique([groupId]) @@index([guildId]) @@map("reaction_role_messages") } @@ -593,6 +597,25 @@ model ReactionRoleMapping { @@map("reaction_role_mappings") } +model RoleGroup { + id String @id @default(cuid()) + guildId String + guild Guild @relation(fields: [guildId], references: [discordId], onDelete: Cascade) + name String + color String? // hex "0x5865F2" (matches UserPreferences.embedColor); converted to int for Discord + hoist Boolean @default(false) + mentionable Boolean @default(false) + buttonStyle String? // "Primary" | "Secondary" | "Success" | "Danger" + defaultEmoji String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + messages ReactionRoleMessage[] + + @@index([guildId]) + @@map("role_groups") +} + // Last.fm per-user linking (session key stored per Discord user) model LastFmLink { id String @id @default(cuid()) @@ -994,11 +1017,11 @@ model SupportReport { // Top.gg vote tracking: dedup (12h TTL) + streak counter (36h reset). // One row per Discord user who has ever voted; no FK to users table. model TopggVote { - id String @id @default(cuid()) - userId String @unique // Discord user snowflake + id String @id @default(cuid()) + userId String @unique // Discord user snowflake lastVoteAt DateTime - streak Int @default(1) - updatedAt DateTime @updatedAt + streak Int @default(1) + updatedAt DateTime @updatedAt @@map("topgg_votes") } @@ -1067,13 +1090,13 @@ model Session { // forum thread tagged `` is created or updated. // Used by the web app to render per-guia "Ver discussão no Discord" CTAs. model GuildForumThread { - id String @id @default(cuid()) - guildId String - guild Guild @relation(fields: [guildId], references: [discordId], onDelete: Cascade) - threadId String - slug String - title String - archived Boolean @default(false) + id String @id @default(cuid()) + guildId String + guild Guild @relation(fields: [guildId], references: [discordId], onDelete: Cascade) + threadId String + slug String + title String + archived Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt