diff --git a/packages/backend/src/errors/AppError.ts b/packages/backend/src/errors/AppError.ts index 2f47ad6ea..d2d715d89 100644 --- a/packages/backend/src/errors/AppError.ts +++ b/packages/backend/src/errors/AppError.ts @@ -32,6 +32,10 @@ export class AppError extends Error { return new AppError(415, message) } + static badGateway(message = 'Bad gateway'): AppError { + return new AppError(502, message) + } + static serviceUnavailable(message = 'Service unavailable'): AppError { return new AppError(503, message) } diff --git a/packages/backend/src/routes/guilds.ts b/packages/backend/src/routes/guilds.ts index 369b4c837..8019722b8 100644 --- a/packages/backend/src/routes/guilds.ts +++ b/packages/backend/src/routes/guilds.ts @@ -140,4 +140,16 @@ export function setupGuildRoutes(app: Express): void { res.json({ roles }) }), ) + + app.get( + '/api/guilds/:guildId/emojis', + requireAuth, + validateParams(guildIdParam), + requireGuildModuleAccess('overview'), + asyncHandler(async (req: AuthenticatedRequest, res: Response) => { + const guildId = getGuildId(req) + const emojis = await guildService.getGuildEmojis(guildId) + res.json({ emojis }) + }), + ) } diff --git a/packages/backend/src/routes/roles.ts b/packages/backend/src/routes/roles.ts index b1ad3664b..573b9edc5 100644 --- a/packages/backend/src/routes/roles.ts +++ b/packages/backend/src/routes/roles.ts @@ -1,4 +1,4 @@ -import type { Express, Response } from 'express' +import type { Express, Response, NextFunction } from 'express' import { requireAuth, type AuthenticatedRequest } from '../middleware/auth' import { requireGuildModuleAccess } from '../middleware/guildAccess' import { validateParams, validateBody } from '../middleware/validate' @@ -11,11 +11,88 @@ import { roleManagementService, } from '@lucky/shared/services' import { guildService } from '../services/GuildService' +import multer from 'multer' function p(val: string | string[]): string { return typeof val === 'string' ? val : val[0] } +// File upload middleware for reaction roles images +const imageUpload = multer({ + storage: multer.memoryStorage(), + // Bound every multipart dimension, not just the file, so a malformed/hostile + // request can't exhaust memory (DoS): one 8MB image + the small JSON payload. + limits: { + fileSize: 8 * 1024 * 1024, // 8MB per file + files: 1, + fields: 20, + fieldSize: 256 * 1024, // the `payload` JSON field + parts: 25, + }, + fileFilter: (req, file, cb) => { + const validMimetypes = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + ] + if (validMimetypes.includes(file.mimetype)) { + cb(null, true) + } else { + cb( + new Error( + 'Invalid image file type. Only PNG, JPEG, GIF, and WebP are allowed', + ), + ) + } + }, +}) + +// Wrapper to handle multer errors +const handleImageUpload = imageUpload.single('image') +const imageUploadHandler = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, +) => { + handleImageUpload(req, res, (err: unknown) => { + if (err instanceof multer.MulterError) { + if (err.code === 'LIMIT_FILE_SIZE') { + return next( + AppError.payloadTooLarge('File size exceeds 8MB limit'), + ) + } + return next(AppError.badRequest(err.message)) + } else if (err) { + return next( + AppError.badRequest( + err instanceof Error ? err.message : 'Image upload failed', + ), + ) + } + next() + }) +} + +// Parse the reaction-role payload from either a JSON body or the `payload` +// field of a multipart (file-upload) request. +function parseReactionRolePayload(req: AuthenticatedRequest): unknown { + if (req.is('multipart/form-data')) { + const raw = (req.body as Record).payload + if (typeof raw !== 'string') { + throw AppError.badRequest( + 'Missing payload field in multipart request', + ) + } + try { + return JSON.parse(raw) as unknown + } catch { + throw AppError.badRequest('Invalid JSON in payload field') + } + } + return req.body +} + export function setupRolesRoutes(app: Express): void { app.get( '/api/guilds/:guildId/reaction-roles', @@ -35,32 +112,117 @@ export function setupRolesRoutes(app: Express): void { requireAuth, requireGuildModuleAccess('overview', 'manage'), validateParams(s.guildIdParam), - validateBody(s.createReactionRoleBody), + imageUploadHandler, asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) const botToken = process.env.DISCORD_TOKEN?.trim() if (!botToken) { throw AppError.serviceUnavailable('Bot token not configured') } - const { channelId, title, description, roles } = req.body as { - channelId: string - title: string - description: string - roles: Array<{ - roleId: string - label: string - emoji?: string - style?: 'Primary' | 'Secondary' | 'Success' | 'Danger' - }> + + const payload = parseReactionRolePayload(req) + + // Validate parsed payload with schema + const validationResult = s.createReactionRoleBody.safeParse(payload) + if (!validationResult.success) { + const errors = validationResult.error.flatten() + throw AppError.badRequest( + `Validation failed: ${JSON.stringify(errors)}`, + ) } + + const { channelId, title, description, imageUrl, roles } = + validationResult.data + + const imageFile = req.file + ? { + buffer: req.file.buffer, + filename: req.file.originalname, + contentType: req.file.mimetype, + } + : undefined + const result = await reactionRolesService.createReactionRoleMessageFromDashboard( - { guildId, channelId, title, description, botToken, roles }, + { + guildId, + channelId, + title, + description, + imageUrl, + imageFile, + botToken, + roles, + }, ) res.status(201).json(result) }), ) + app.put( + '/api/guilds/:guildId/reaction-roles/:messageId', + requireAuth, + writeLimiter, + requireGuildModuleAccess('overview', 'manage'), + validateParams(s.messageIdParam), + imageUploadHandler, + asyncHandler(async (req: AuthenticatedRequest, res: Response) => { + const guildId = p(req.params.guildId) + const messageId = p(req.params.messageId) + const botToken = process.env.DISCORD_TOKEN?.trim() + if (!botToken) { + throw AppError.serviceUnavailable('Bot token not configured') + } + + const payload = parseReactionRolePayload(req) + + // Validate parsed payload with schema + const validationResult = s.updateReactionRoleBody.safeParse(payload) + if (!validationResult.success) { + const errors = validationResult.error.flatten() + throw AppError.badRequest( + `Validation failed: ${JSON.stringify(errors)}`, + ) + } + + const { title, description, imageUrl, roles } = + validationResult.data + + const imageFile = req.file + ? { + buffer: req.file.buffer, + filename: req.file.originalname, + contentType: req.file.mimetype, + } + : undefined + + try { + const result = + await reactionRolesService.updateReactionRoleMessage({ + guildId, + messageId, + title, + description, + imageUrl, + imageFile, + botToken, + roles, + }) + res.json(result) + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown error' + if (message === 'Reaction role message not found') { + throw AppError.notFound('Reaction role message not found') + } + if (message.startsWith('Discord API error')) { + throw AppError.badGateway(message) + } + throw AppError.badRequest(message) + } + }), + ) + app.delete( '/api/guilds/:guildId/reaction-roles/:messageId', requireAuth, @@ -125,6 +287,12 @@ export function setupRolesRoutes(app: Express): void { error instanceof Error ? error.message : 'Failed to create role' + if ( + message.startsWith('Discord API error') || + message === 'No bot token available' + ) { + throw AppError.badGateway(message) + } throw AppError.badRequest(message) } }), @@ -157,6 +325,12 @@ export function setupRolesRoutes(app: Express): void { if (message === 'Role not found') { throw AppError.notFound('Role not found') } + if ( + message.startsWith('Discord API error') || + message === 'No bot token available' + ) { + throw AppError.badGateway(message) + } throw AppError.badRequest(message) } }), @@ -183,6 +357,12 @@ export function setupRolesRoutes(app: Express): void { if (message === 'Role not found') { throw AppError.notFound(message) } + if ( + message.startsWith('Discord API error') || + message === 'No bot token available' + ) { + throw AppError.badGateway(message) + } throw AppError.badRequest(message) } }), diff --git a/packages/backend/src/schemas/management.ts b/packages/backend/src/schemas/management.ts index f81c1032d..35def6fb9 100644 --- a/packages/backend/src/schemas/management.ts +++ b/packages/backend/src/schemas/management.ts @@ -111,6 +111,24 @@ const createReactionRoleBody = z channelId: z.string().regex(/^\d{17,20}$/, 'Invalid channel ID'), title: z.string().min(1).max(256), description: z.string().min(1).max(4096), + imageUrl: z.string().url().max(2048).optional(), + roles: z.array(reactionRoleEntrySchema).min(1).max(25), + }) + .strict() + .refine( + (data) => + new Set(data.roles.map((r) => r.roleId)).size === data.roles.length, + { + message: 'Duplicate roleId entries are not allowed', + path: ['roles'], + }, + ) + +const updateReactionRoleBody = z + .object({ + title: z.string().min(1).max(256), + description: z.string().min(1).max(4096), + imageUrl: z.string().url().max(2048).optional(), roles: z.array(reactionRoleEntrySchema).min(1).max(25), }) .strict() @@ -143,5 +161,6 @@ export const managementSchemas = { roleUpsertBody, bulkDeleteBody, createReactionRoleBody, + updateReactionRoleBody, messageIdParam, } diff --git a/packages/backend/src/services/GuildService.ts b/packages/backend/src/services/GuildService.ts index d6ab1709c..bc120a638 100644 --- a/packages/backend/src/services/GuildService.ts +++ b/packages/backend/src/services/GuildService.ts @@ -69,6 +69,12 @@ export interface GuildChannelOption { name: string } +export interface GuildEmojiOption { + id: string + name: string + animated: boolean +} + export function setBotClient(client: Client | null): void { botClient = client guildService.clearBotGuildCache() @@ -657,6 +663,83 @@ class GuildService { } } + async getGuildEmojis(guildId: string): Promise { + const client = this.getBotClient() + + if (client) { + try { + const guild = await this.getServableGuild(guildId) + if (guild) { + return [...guild.emojis.cache.values()].map((emoji) => ({ + id: emoji.id, + name: emoji.name ?? '', + animated: emoji.animated ?? false, + })) + } + } catch (error) { + debugLog({ + message: 'Failed to fetch guild emojis from bot client', + error, + }) + } + } + + const token = this.getBotToken() + if (!token) { + return [] + } + + // Reject anything that is not a Discord snowflake before it reaches the + // request URL — validated inline at the sink so the ID cannot forge the + // request (SSRF / path-traversal guard). + if (!/^\d{17,20}$/.test(guildId)) { + throw new Error('Invalid Discord guild id') + } + + try { + const response = await fetch( + `${DISCORD_API_BASE_URL}/guilds/${guildId}/emojis`, + { + headers: { + Authorization: `Bot ${token}`, + }, + signal: AbortSignal.timeout(10_000), + }, + ) + + if (!response.ok) { + throw new Error(`Discord API error: ${response.status}`) + } + + const payload = (await response.json()) as unknown[] + + return payload + .filter( + ( + emoji, + ): emoji is { + id: string + name?: string + animated?: boolean + } => + typeof emoji === 'object' && + emoji !== null && + typeof (emoji as { id?: unknown }).id === 'string', + ) + .map((emoji) => ({ + id: emoji.id, + name: emoji.name ?? '', + animated: emoji.animated ?? false, + })) + } catch (error) { + errorLog({ + message: 'Failed to fetch guild emojis', + error, + }) + throw error + } + } + async getUserGuilds(accessToken: string): Promise { try { const allGuilds = diff --git a/packages/backend/tests/integration/routes/roles.test.ts b/packages/backend/tests/integration/routes/roles.test.ts index 0a4c4770b..3bcf7a4e0 100644 --- a/packages/backend/tests/integration/routes/roles.test.ts +++ b/packages/backend/tests/integration/routes/roles.test.ts @@ -50,6 +50,7 @@ describe('Roles Routes', () => { setupRolesRoutes(app) app.use(errorHandler) jest.clearAllMocks() + process.env.DISCORD_TOKEN = 'test-token-default' }) const GUILD_ID = '111111111111111111' @@ -382,4 +383,189 @@ describe('Roles Routes', () => { expect(res.body.exclusions).toHaveLength(0) }) }) + + describe('POST /api/guilds/:guildId/reaction-roles with file upload', () => { + const CHANNEL_ID = '222222222222222222' + const ROLE_ID = '333333333333333333' + + test('should create reaction role message with multipart file upload', async () => { + authed() + const fakeImageBuffer = Buffer.from('fake-png-data') + const createdMessage = { + id: 'rrm-created', + messageId: '444444444444444444', + channelId: CHANNEL_ID, + guildId: GUILD_ID, + mappings: [], + } + mockCreateReactionRole.mockResolvedValue(createdMessage) + process.env.DISCORD_TOKEN = 'test-token' + + const res = await request(app) + .post(`/api/guilds/${GUILD_ID}/reaction-roles`) + .set('Cookie', ['sessionId=valid_session_id']) + .field( + 'payload', + JSON.stringify({ + channelId: CHANNEL_ID, + title: 'Test Roles', + description: 'Test description', + roles: [ + { + roleId: ROLE_ID, + label: 'Test Role', + emoji: '✅', + style: 'Primary', + }, + ], + }), + ) + .attach('image', fakeImageBuffer, 'test-image.png') + + expect(res.status).toBe(201) + expect(res.body).toEqual(createdMessage) + expect(mockCreateReactionRole).toHaveBeenCalledWith( + expect.objectContaining({ + guildId: GUILD_ID, + channelId: CHANNEL_ID, + imageFile: expect.objectContaining({ + filename: 'test-image.png', + }), + }), + ) + }) + + test('should reject multipart POST with oversized file', async () => { + authed() + // Create an 9MB buffer (exceeds 8MB limit) + const largeBuffer = Buffer.alloc(9 * 1024 * 1024) + process.env.DISCORD_TOKEN = 'test-token' + + const res = await request(app) + .post(`/api/guilds/${GUILD_ID}/reaction-roles`) + .set('Cookie', ['sessionId=valid_session_id']) + .field( + 'payload', + JSON.stringify({ + channelId: CHANNEL_ID, + title: 'Test Roles', + description: 'Test description', + roles: [ + { + roleId: ROLE_ID, + label: 'Test Role', + }, + ], + }), + ) + .attach('image', largeBuffer, 'big-file.png') + + expect(res.status).toBe(413) + }) + + test('should reject multipart POST with invalid image mimetype', async () => { + authed() + const textBuffer = Buffer.from('not an image') + process.env.DISCORD_TOKEN = 'test-token' + + const res = await request(app) + .post(`/api/guilds/${GUILD_ID}/reaction-roles`) + .set('Cookie', ['sessionId=valid_session_id']) + .field( + 'payload', + JSON.stringify({ + channelId: CHANNEL_ID, + title: 'Test Roles', + description: 'Test description', + roles: [ + { + roleId: ROLE_ID, + label: 'Test Role', + }, + ], + }), + ) + .attach('image', textBuffer, 'not-an-image.txt') + + expect(res.status).toBe(400) + }) + + test('should still accept normal JSON POST without file', async () => { + authed() + const createdMessage = { + id: 'rrm-created', + messageId: '444444444444444444', + channelId: CHANNEL_ID, + guildId: GUILD_ID, + mappings: [], + } + mockCreateReactionRole.mockResolvedValue(createdMessage) + process.env.DISCORD_TOKEN = 'test-token' + + const res = await request(app) + .post(`/api/guilds/${GUILD_ID}/reaction-roles`) + .set('Cookie', ['sessionId=valid_session_id']) + .send({ + channelId: CHANNEL_ID, + title: 'Test Roles', + description: 'Test description', + roles: [ + { + roleId: ROLE_ID, + label: 'Test Role', + }, + ], + }) + + expect(res.status).toBe(201) + expect(mockCreateReactionRole).toHaveBeenCalledWith( + expect.objectContaining({ + guildId: GUILD_ID, + channelId: CHANNEL_ID, + imageFile: undefined, + }), + ) + }) + }) + + describe('PUT /api/guilds/:guildId/reaction-roles/:messageId with file upload', () => { + const MESSAGE_ID = '555555555555555555' + const ROLE_ID = '333333333333333333' + + test('should update reaction role message with multipart file upload', async () => { + authed() + const fakeImageBuffer = Buffer.from('updated-png-data') + process.env.DISCORD_TOKEN = 'test-token' + + const mockUpdateReactionRole = jest.fn() + ;( + jest.mocked( + require('@lucky/shared/services').reactionRolesService, + ) as any + ).updateReactionRoleMessage = mockUpdateReactionRole + + mockUpdateReactionRole.mockResolvedValue({ messageId: MESSAGE_ID }) + + const res = await request(app) + .put(`/api/guilds/${GUILD_ID}/reaction-roles/${MESSAGE_ID}`) + .set('Cookie', ['sessionId=valid_session_id']) + .field( + 'payload', + JSON.stringify({ + title: 'Updated Roles', + description: 'Updated description', + roles: [ + { + roleId: ROLE_ID, + label: 'Updated Role', + }, + ], + }), + ) + .attach('image', fakeImageBuffer, 'updated-image.png') + + expect(res.status).toBe(200) + expect(res.body).toEqual({ messageId: MESSAGE_ID }) + }) + }) }) diff --git a/packages/backend/tests/unit/services/GuildService.emojis.test.ts b/packages/backend/tests/unit/services/GuildService.emojis.test.ts new file mode 100644 index 000000000..599b6f4aa --- /dev/null +++ b/packages/backend/tests/unit/services/GuildService.emojis.test.ts @@ -0,0 +1,189 @@ +import { + describe, + test, + expect, + beforeEach, + afterEach, + jest, +} from '@jest/globals' +import { guildService, setBotClient } from '../../../src/services/GuildService' +import type { Client, Guild } from 'discord.js' + +const originalFetch = global.fetch + +describe('GuildService - getGuildEmojis', () => { + let originalDiscordToken: string | undefined + + beforeEach(() => { + jest.clearAllMocks() + setBotClient(null) + originalDiscordToken = process.env.DISCORD_TOKEN + delete process.env.DISCORD_TOKEN + global.fetch = originalFetch + }) + + afterEach(() => { + if (originalDiscordToken === undefined) { + delete process.env.DISCORD_TOKEN + } else { + process.env.DISCORD_TOKEN = originalDiscordToken + } + global.fetch = originalFetch + }) + + test('should return mapped emojis from bot client when available', async () => { + const mockGuild = { + id: '111111111111111111', + emojis: { + cache: new Map([ + [ + 'emoji1', + { + id: 'emoji1', + name: 'happy', + animated: false, + }, + ], + [ + 'emoji2', + { + id: 'emoji2', + name: 'sad', + animated: true, + }, + ], + [ + 'emoji3', + { + id: 'emoji3', + name: 'love', + animated: false, + }, + ], + ]), + }, + } as unknown as Guild + + const mockClient = { + guilds: { + cache: new Map([['111111111111111111', mockGuild]]), + fetch: jest.fn(), + }, + } as unknown as Client + + setBotClient(mockClient) + + const result = await guildService.getGuildEmojis('111111111111111111') + + expect(result).toEqual([ + { id: 'emoji1', name: 'happy', animated: false }, + { id: 'emoji2', name: 'sad', animated: true }, + { id: 'emoji3', name: 'love', animated: false }, + ]) + expect(mockClient.guilds.fetch).not.toHaveBeenCalled() + }) + + test('should fallback to Discord API for emojis when bot client is unavailable', async () => { + process.env.DISCORD_TOKEN = 'test-bot-token' + setBotClient(null) + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => [ + { id: 'emoji-a', name: 'cool', animated: false }, + { id: 'emoji-b', name: 'fire', animated: true }, + { id: 'emoji-c', name: 'party', animated: false }, + ], + } as never) as unknown as typeof fetch + + const result = await guildService.getGuildEmojis('111111111111111111') + + expect(result).toEqual([ + { id: 'emoji-a', name: 'cool', animated: false }, + { id: 'emoji-b', name: 'fire', animated: true }, + { id: 'emoji-c', name: 'party', animated: false }, + ]) + expect(global.fetch).toHaveBeenCalledWith( + 'https://discord.com/api/v10/guilds/111111111111111111/emojis', + { + headers: { + Authorization: 'Bot test-bot-token', + }, + signal: expect.any(AbortSignal), + }, + ) + }) + + test('should filter out entries without id on REST fallback', async () => { + process.env.DISCORD_TOKEN = 'test-bot-token' + setBotClient(null) + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => [ + { id: 'emoji-a', name: 'cool', animated: false }, + { name: 'invalid', animated: false }, // Missing id + { id: 'emoji-b', name: 'fire', animated: true }, + ], + } as never) as unknown as typeof fetch + + const result = await guildService.getGuildEmojis('111111111111111111') + + expect(result).toEqual([ + { id: 'emoji-a', name: 'cool', animated: false }, + { id: 'emoji-b', name: 'fire', animated: true }, + ]) + }) + + test('should throw on REST API error', async () => { + process.env.DISCORD_TOKEN = 'test-bot-token' + setBotClient(null) + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + text: async () => 'Not Found', + } as never) as unknown as typeof fetch + + await expect( + guildService.getGuildEmojis('111111111111111111'), + ).rejects.toThrow('Discord API error: 404') + }) + + test('should throw on fetch network error', async () => { + process.env.DISCORD_TOKEN = 'test-bot-token' + setBotClient(null) + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')) + + await expect( + guildService.getGuildEmojis('111111111111111111'), + ).rejects.toThrow('Network error') + }) + + test('should return empty array when no bot client and no token', async () => { + setBotClient(null) + // no token set + + const result = await guildService.getGuildEmojis('111111111111111111') + + expect(result).toEqual([]) + }) + + test('should return empty array from bot client when guild has no emojis', async () => { + const mockGuild = { + id: '111111111111111111', + emojis: { + cache: new Map([]), + }, + } as unknown as Guild + + const mockClient = { + guilds: { + cache: new Map([['111111111111111111', mockGuild]]), + }, + } as unknown as Client + + setBotClient(mockClient) + + const result = await guildService.getGuildEmojis('111111111111111111') + + expect(result).toEqual([]) + }) +}) diff --git a/packages/frontend/src/components/reactionRoles/ImportDialog.test.tsx b/packages/frontend/src/components/reactionRoles/ImportDialog.test.tsx new file mode 100644 index 000000000..5715b213e --- /dev/null +++ b/packages/frontend/src/components/reactionRoles/ImportDialog.test.tsx @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { ImportDialog } from './ImportDialog' +import { api } from '@/services/api' + +vi.mock('@/services/api', () => ({ + api: { reactionRoles: { create: vi.fn() } }, +})) + +const createMock = vi.mocked(api.reactionRoles.create) + +const validItem = { + channelId: '11111111111111111', + title: 'Pick roles', + description: 'Choose below', + roles: [{ roleId: '22222222222222222', label: 'Gamer', style: 'Primary' }], +} +const validJson = JSON.stringify([validItem, { ...validItem, title: 'Two' }]) + +function renderDialog(props: Partial[0]> = {}) { + const onClose = vi.fn() + const onSuccess = vi.fn() + render( + , + ) + return { onClose, onSuccess } +} + +describe('ImportDialog', () => { + beforeEach(() => { + createMock.mockReset() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('renders the dialog with paste + file inputs', () => { + renderDialog() + expect(screen.getByText('Import Reaction Roles')).toBeInTheDocument() + expect(screen.getByLabelText('Paste JSON')).toBeInTheDocument() + expect(screen.getByLabelText('Or upload a file')).toBeInTheDocument() + }) + + it('disables Import until JSON is entered', () => { + renderDialog() + const importBtn = screen.getByRole('button', { name: 'Import' }) + expect(importBtn).toBeDisabled() + fireEvent.change(screen.getByLabelText('Paste JSON'), { + target: { value: validJson }, + }) + expect(importBtn).toBeEnabled() + }) + + it('shows validation errors and does not call the API for invalid JSON', () => { + renderDialog() + fireEvent.change(screen.getByLabelText('Paste JSON'), { + target: { value: '{not valid json' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Import' })) + expect(screen.getByText('Validation errors')).toBeInTheDocument() + expect(createMock).not.toHaveBeenCalled() + }) + + it('reads an uploaded .json file into the paste field', async () => { + renderDialog() + const file = new File([validJson], 'roles.json', { + type: 'application/json', + }) + fireEvent.change(screen.getByLabelText('Or upload a file'), { + target: { files: [file] }, + }) + await waitFor(() => + expect( + (screen.getByLabelText('Paste JSON') as HTMLTextAreaElement) + .value, + ).toContain('Pick roles'), + ) + }) + + it('ignores a file-select with no file', () => { + renderDialog() + fireEvent.change(screen.getByLabelText('Or upload a file'), { + target: { files: [] }, + }) + expect( + (screen.getByLabelText('Paste JSON') as HTMLTextAreaElement).value, + ).toBe('') + }) + + it('creates each message and resets + closes on full success', async () => { + vi.useFakeTimers() + createMock.mockResolvedValue({ messageId: 'm' }) + const { onClose, onSuccess } = renderDialog() + fireEvent.change(screen.getByLabelText('Paste JSON'), { + target: { value: validJson }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Import' })) + + await vi.waitFor(() => expect(createMock).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => + expect( + screen.getByText(/Successfully created 2 message/), + ).toBeInTheDocument(), + ) + vi.advanceTimersByTime(1500) + expect(onSuccess).toHaveBeenCalled() + expect(onClose).toHaveBeenCalled() + }) + + it('reports per-item errors without aborting the batch', async () => { + createMock + .mockResolvedValueOnce({ messageId: 'm1' }) + .mockRejectedValueOnce(new Error('boom')) + renderDialog() + fireEvent.change(screen.getByLabelText('Paste JSON'), { + target: { value: validJson }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Import' })) + + await waitFor(() => expect(createMock).toHaveBeenCalledTimes(2)) + expect( + await screen.findByText(/Completed with 1 error/), + ).toBeInTheDocument() + expect(screen.getByText(/boom/)).toBeInTheDocument() + }) +}) diff --git a/packages/frontend/src/components/reactionRoles/ImportDialog.tsx b/packages/frontend/src/components/reactionRoles/ImportDialog.tsx new file mode 100644 index 000000000..54e771452 --- /dev/null +++ b/packages/frontend/src/components/reactionRoles/ImportDialog.tsx @@ -0,0 +1,205 @@ +import { useState } from 'react' +import { AlertCircle, Loader2 } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import Button from '@/components/ui/Button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import AutoGrowTextarea from '@/components/ui/AutoGrowTextarea' +import { deserializeReactionRolesJSON } from '@/utils/reactionRolesExport' +import Card from '@/components/ui/Card' +import { api } from '@/services/api' + +interface ImportDialogProps { + isOpen: boolean + onClose: () => void + guildId: string + onSuccess: () => void +} + +export function ImportDialog({ + isOpen, + onClose, + guildId, + onSuccess, +}: ImportDialogProps) { + const [jsonInput, setJsonInput] = useState('') + const [validationErrors, setValidationErrors] = useState([]) + const [isImporting, setIsImporting] = useState(false) + const [importProgress, setImportProgress] = useState('') + const [importErrors, setImportErrors] = useState>({}) + function handleJsonChange(value: string) { + setJsonInput(value) + setValidationErrors([]) + setImportErrors({}) + } + + function handleFileSelect(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + + const reader = new FileReader() + reader.onload = (event) => { + const content = event.target?.result as string + handleJsonChange(content) + } + reader.readAsText(file) + } + + async function handleImport() { + const result = deserializeReactionRolesJSON(jsonInput) + + if (!result.valid) { + setValidationErrors(result.errors) + return + } + + setImportProgress('') + setImportErrors({}) + setIsImporting(true) + + const errors: Record = {} + let successCount = 0 + + for (let i = 0; i < result.data.length; i++) { + const payload = result.data[i] + setImportProgress(`Creating ${i + 1}/${result.data.length}...`) + + try { + await api.reactionRoles.create(guildId, payload) + successCount++ + } catch (error) { + errors[i] = + error instanceof Error ? error.message : 'Unknown error' + } + } + + setIsImporting(false) + + if (Object.keys(errors).length > 0) { + setImportErrors(errors) + setImportProgress( + `Completed with ${Object.keys(errors).length} error(s). ${successCount} created.`, + ) + } else { + setImportProgress( + `Successfully created ${successCount} message(s)!`, + ) + setTimeout(() => { + setJsonInput('') + setValidationErrors([]) + setImportProgress('') + onSuccess() + onClose() + }, 1500) + } + } + + return ( + + + + Import Reaction Roles + + +
+
+ + + handleJsonChange(e.currentTarget.value) + } + disabled={isImporting} + className='min-h-[200px]' + /> +
+ +
+ + +
+ + {validationErrors.length > 0 && ( + +
+ +
+

+ Validation errors +

+ {validationErrors.map((error, i) => ( +

+ • {error} +

+ ))} +
+
+
+ )} + + {importProgress && ( + +

+ {importProgress} +

+
+ )} + + {Object.keys(importErrors).length > 0 && ( + +

+ Import errors +

+ {Object.entries(importErrors).map( + ([index, error]) => ( +

+ • Item {index}: {error} +

+ ), + )} +
+ )} +
+ + + + + +
+
+ ) +} diff --git a/packages/frontend/src/components/ui/AutoGrowTextarea.tsx b/packages/frontend/src/components/ui/AutoGrowTextarea.tsx new file mode 100644 index 000000000..8b8edbcb7 --- /dev/null +++ b/packages/frontend/src/components/ui/AutoGrowTextarea.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef, forwardRef } from 'react' + +interface AutoGrowTextareaProps extends React.TextareaHTMLAttributes { + minRows?: number + maxRows?: number +} + +const AutoGrowTextarea = forwardRef( + ( + { + minRows = 3, + maxRows = 12, + className = '', + value, + onChange, + ...props + }, + ref, + ) => { + const textareaRef = useRef(null) + + useEffect(() => { + const textarea = + ref && typeof ref !== 'function' + ? ref.current + : textareaRef.current + if (!textarea) return + + // Reset height to calculate scrollHeight + textarea.style.height = 'auto' + + // Get line-height from computed styles + const lineHeight = parseFloat( + window.getComputedStyle(textarea).lineHeight, + ) + + // Calculate ideal height based on scrollHeight + const scrollHeight = textarea.scrollHeight + const minHeight = lineHeight * minRows + const maxHeight = lineHeight * maxRows + + let newHeight = scrollHeight + if (newHeight < minHeight) { + newHeight = minHeight + } else if (newHeight > maxHeight) { + newHeight = maxHeight + } + + textarea.style.height = `${newHeight}px` + textarea.style.overflowY = + scrollHeight > maxHeight ? 'auto' : 'hidden' + }, [value, minRows, maxRows, ref]) + + return ( +