diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index 220baf3..c663e21 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -19,6 +19,7 @@ import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/inde import { pollAllFeeds } from './modules/feeds/index.js'; import { sendScheduledMessage } from './modules/scheduler/index.js'; import { runGuildBackupRestoreJob } from './modules/guildbackup/index.js'; +import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js'; import { automodQueue, automodQueueName, @@ -33,6 +34,7 @@ import { moderationQueueName, reminderQueueName, scheduleQueueName, + suggestionsQueueName, ticketQueue, ticketQueueName, verificationQueueName @@ -95,6 +97,13 @@ type BirthdayRoleExpireJob = { roleId: string; }; +type SuggestionStatusUpdateJob = { + suggestionId: string; + guildId: string; + status: 'OPEN' | 'APPROVED' | 'DENIED' | 'CONSIDERED' | 'IMPLEMENTED'; + reason: string; +}; + export function startWorkers(context: BotContext): Worker[] { const moderationWorker = new Worker( moderationQueueName, @@ -281,6 +290,20 @@ export function startWorkers(context: BotContext): Worker[] { logger.error({ jobId: job?.id, error }, 'Guild backup restore job failed'); }); + const suggestionsWorker = new Worker( + suggestionsQueueName, + async (job) => { + if (job.name !== 'suggestionStatusUpdate') { + return; + } + return runSuggestionStatusUpdateJob(context, job.data); + }, + { connection: redis } + ); + suggestionsWorker.on('failed', (job, error) => { + logger.error({ jobId: job?.id, error }, 'Suggestion status update job failed'); + }); + return [ moderationWorker, backupWorker, @@ -293,7 +316,8 @@ export function startWorkers(context: BotContext): Worker[] { statsWorker, feedsWorker, scheduleWorker, - guildBackupWorker + guildBackupWorker, + suggestionsWorker ]; } diff --git a/apps/bot/src/modules/suggestions/index.ts b/apps/bot/src/modules/suggestions/index.ts index 05f4582..dd5c350 100644 --- a/apps/bot/src/modules/suggestions/index.ts +++ b/apps/bot/src/modules/suggestions/index.ts @@ -1,2 +1,3 @@ export { suggestionsCommands } from './commands.js'; export { isSuggestionButton, handleSuggestionButton } from './buttons.js'; +export { runSuggestionStatusUpdateJob } from './service.js'; diff --git a/apps/bot/src/modules/suggestions/service.ts b/apps/bot/src/modules/suggestions/service.ts index 3163807..0a8104c 100644 --- a/apps/bot/src/modules/suggestions/service.ts +++ b/apps/bot/src/modules/suggestions/service.ts @@ -10,6 +10,7 @@ import { SuggestionStatusSchema, t, tf, type SuggestionStatus } from '@nexumi/sh import type { Locale } from '@nexumi/shared'; import { ensureGuild } from '../../guild.js'; import { logger } from '../../logger.js'; +import { getGuildLocale } from '../../i18n.js'; import type { BotContext } from '../../types.js'; export const SUGGESTION_VOTE_PREFIX = 'sug:vote:'; @@ -339,3 +340,24 @@ export async function updateSuggestionStatus( await notifyAuthor(context, updated, locale, params.status, params.reason); return updated; } + +/** + * Entry point for the `suggestionStatusUpdate` BullMQ job, enqueued by the + * WebUI dashboard (which has no discord.js Client to move/edit the + * suggestion embed or DM the author). Resolves the guild's locale and + * delegates to {@link updateSuggestionStatus} so staff actions performed + * from the dashboard behave identically to `/suggestion approve|deny|...`. + */ +export async function runSuggestionStatusUpdateJob( + context: BotContext, + params: { + suggestionId: string; + guildId: string; + status: SuggestionStatus; + reason: string; + } +): Promise<{ id: string }> { + const locale = await getGuildLocale(context.prisma, params.guildId); + const updated = await updateSuggestionStatus(context, params, locale); + return { id: updated.id }; +} diff --git a/apps/bot/src/queues.ts b/apps/bot/src/queues.ts index da49982..904d1dd 100644 --- a/apps/bot/src/queues.ts +++ b/apps/bot/src/queues.ts @@ -25,3 +25,5 @@ export const scheduleQueueName = 'schedules'; export const scheduleQueue = new Queue(scheduleQueueName, { connection: redis }); export const guildBackupQueueName = 'guild-backups'; export const guildBackupQueue = new Queue(guildBackupQueueName, { connection: redis }); +export const suggestionsQueueName = 'suggestions'; +export const suggestionsQueue = new Queue(suggestionsQueueName, { connection: redis }); diff --git a/apps/webui/src/app/api/guilds/[guildId]/feeds/[feedId]/route.ts b/apps/webui/src/app/api/guilds/[guildId]/feeds/[feedId]/route.ts new file mode 100644 index 0000000..56dc7ba --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/feeds/[feedId]/route.ts @@ -0,0 +1,59 @@ +import { SocialFeedDashboardUpdateSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { deleteFeed, updateFeed } from '@/lib/module-configs/feeds'; +import { prisma } from '@/lib/prisma'; + +interface RouteParams { + params: Promise<{ guildId: string; feedId: string }>; +} + +export async function PATCH(request: NextRequest, { params }: RouteParams) { + const { guildId, feedId } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = await request.json(); + const patch = SocialFeedDashboardUpdateSchema.parse(body); + + const updated = await updateFeed(guildId, feedId, patch); + if (!updated) { + return NextResponse.json({ error: 'Feed not found' }, { status: 404 }); + } + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'feeds.update', + path: `/dashboard/${guildId}/feeds`, + after: updated + }); + + return NextResponse.json(updated); + } catch (error) { + return toApiErrorResponse(error); + } +} + +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + const { guildId, feedId } = await params; + try { + const session = await requireGuildAccess(guildId); + const deleted = await deleteFeed(guildId, feedId); + if (!deleted) { + return NextResponse.json({ error: 'Feed not found' }, { status: 404 }); + } + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'feeds.delete', + path: `/dashboard/${guildId}/feeds`, + before: { id: feedId } + }); + + return NextResponse.json({ ok: true }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/feeds/route.ts b/apps/webui/src/app/api/guilds/[guildId]/feeds/route.ts new file mode 100644 index 0000000..3e7e6ef --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/feeds/route.ts @@ -0,0 +1,44 @@ +import { SocialFeedDashboardCreateSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { createFeed, listFeeds } from '@/lib/module-configs/feeds'; +import { prisma } from '@/lib/prisma'; + +interface RouteParams { + params: Promise<{ guildId: string }>; +} + +export async function GET(_request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + await requireGuildAccess(guildId); + const feeds = await listFeeds(guildId); + return NextResponse.json({ feeds }); + } catch (error) { + return toApiErrorResponse(error); + } +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = await request.json(); + const input = SocialFeedDashboardCreateSchema.parse(body); + + const feed = await createFeed(guildId, input); + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'feeds.create', + path: `/dashboard/${guildId}/feeds`, + after: feed + }); + + return NextResponse.json(feed, { status: 201 }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/suggestions/items/[suggestionId]/action/route.ts b/apps/webui/src/app/api/guilds/[guildId]/suggestions/items/[suggestionId]/action/route.ts index 63cbcff..4caf784 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/suggestions/items/[suggestionId]/action/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/suggestions/items/[suggestionId]/action/route.ts @@ -2,7 +2,7 @@ import { SuggestionActionSchema } from '@nexumi/shared'; import { type NextRequest, NextResponse } from 'next/server'; import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; import { writeDashboardAudit } from '@/lib/audit'; -import { applySuggestionAction } from '@/lib/module-configs/suggestions'; +import { applySuggestionAction, SuggestionActionTimeoutError } from '@/lib/module-configs/suggestions'; import { prisma } from '@/lib/prisma'; interface RouteParams { @@ -29,6 +29,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return NextResponse.json(after); } catch (error) { + if (error instanceof SuggestionActionTimeoutError) { + return NextResponse.json({ error: error.message }, { status: 504 }); + } return toApiErrorResponse(error); } } diff --git a/apps/webui/src/app/dashboard/[guildId]/feeds/page.tsx b/apps/webui/src/app/dashboard/[guildId]/feeds/page.tsx new file mode 100644 index 0000000..45f4186 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/feeds/page.tsx @@ -0,0 +1,22 @@ +import { FeedsManager } from '@/components/modules/feeds-manager'; +import { getLocale, t } from '@/lib/i18n'; +import { listFeeds } from '@/lib/module-configs/feeds'; + +interface FeedsPageProps { + params: Promise<{ guildId: string }>; +} + +export default async function FeedsPage({ params }: FeedsPageProps) { + const { guildId } = await params; + const [locale, feeds] = await Promise.all([getLocale(), listFeeds(guildId)]); + + return ( +
+
+

{t(locale, 'modules.feeds.label')}

+

{t(locale, 'modules.feeds.description')}

+
+ +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/suggestions/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/suggestions/loading.tsx new file mode 100644 index 0000000..7b2f5cb --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/suggestions/loading.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function SuggestionsLoading() { + return ( +
+
+ + +
+ + +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/suggestions/page.tsx b/apps/webui/src/app/dashboard/[guildId]/suggestions/page.tsx new file mode 100644 index 0000000..1050f33 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/suggestions/page.tsx @@ -0,0 +1,28 @@ +import { SuggestionsConfigForm } from '@/components/modules/suggestions-config-form'; +import { SuggestionsListManager } from '@/components/modules/suggestions-list-manager'; +import { getLocale, t } from '@/lib/i18n'; +import { getSuggestionConfigDashboard, listSuggestions } from '@/lib/module-configs/suggestions'; + +interface SuggestionsPageProps { + params: Promise<{ guildId: string }>; +} + +export default async function SuggestionsPage({ params }: SuggestionsPageProps) { + const { guildId } = await params; + const [locale, config, suggestions] = await Promise.all([ + getLocale(), + getSuggestionConfigDashboard(guildId), + listSuggestions(guildId, { limit: 25 }) + ]); + + return ( +
+
+

{t(locale, 'modules.suggestions.label')}

+

{t(locale, 'modules.suggestions.description')}

+
+ + +
+ ); +} diff --git a/apps/webui/src/components/modules/feeds-manager.tsx b/apps/webui/src/components/modules/feeds-manager.tsx new file mode 100644 index 0000000..295ff0a --- /dev/null +++ b/apps/webui/src/components/modules/feeds-manager.tsx @@ -0,0 +1,214 @@ +'use client'; + +import type { SocialFeedDashboard } from '@nexumi/shared'; +import { Plus, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { useTranslations } from '@/components/locale-provider'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; + +const FEED_TYPES: SocialFeedDashboard['type'][] = ['TWITCH', 'YOUTUBE', 'RSS', 'REDDIT']; + +interface LocalFeed extends SocialFeedDashboard { + clientKey: string; + saving?: boolean; +} + +const EMPTY_DRAFT = { + type: 'RSS' as SocialFeedDashboard['type'], + sourceId: '', + channelId: '', + rolePingId: '', + template: '' +}; + +interface FeedsManagerProps { + guildId: string; + initialFeeds: SocialFeedDashboard[]; +} + +export function FeedsManager({ guildId, initialFeeds }: FeedsManagerProps) { + const t = useTranslations(); + const [feeds, setFeeds] = useState( + initialFeeds.map((feed, index) => ({ ...feed, clientKey: feed.id ?? `existing-${index}` })) + ); + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [creating, setCreating] = useState(false); + + async function handleCreate() { + if (!draft.sourceId.trim() || !draft.channelId.trim()) { + toast.error(t('modulePages.feeds.formIncomplete')); + return; + } + setCreating(true); + try { + const response = await fetch(`/api/guilds/${guildId}/feeds`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: draft.type, + sourceId: draft.sourceId.trim(), + channelId: draft.channelId.trim(), + rolePingId: draft.rolePingId.trim() || undefined, + template: draft.template.trim() || null, + enabled: true + }) + }); + const body = (await response.json().catch(() => null)) as (SocialFeedDashboard & { error?: string }) | null; + if (!response.ok || !body) { + toast.error(body?.error ?? t('common.saveError')); + return; + } + setFeeds((prev) => [...prev, { ...body, clientKey: body.id ?? `new-${Date.now()}` }]); + setDraft(EMPTY_DRAFT); + toast.success(t('common.saveSuccess')); + } catch { + toast.error(t('common.saveError')); + } finally { + setCreating(false); + } + } + + async function handleToggle(feed: LocalFeed) { + setFeeds((prev) => prev.map((entry) => (entry.clientKey === feed.clientKey ? { ...entry, saving: true } : entry))); + try { + const response = await fetch(`/api/guilds/${guildId}/feeds/${feed.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: !feed.enabled }) + }); + const body = (await response.json().catch(() => null)) as (SocialFeedDashboard & { error?: string }) | null; + if (!response.ok || !body) { + toast.error(body?.error ?? t('common.saveError')); + return; + } + setFeeds((prev) => + prev.map((entry) => (entry.clientKey === feed.clientKey ? { ...body, clientKey: entry.clientKey } : entry)) + ); + } catch { + toast.error(t('common.saveError')); + } finally { + setFeeds((prev) => + prev.map((entry) => (entry.clientKey === feed.clientKey ? { ...entry, saving: false } : entry)) + ); + } + } + + async function handleDelete(feed: LocalFeed) { + if (!window.confirm(t('modulePages.feeds.confirmDelete'))) { + return; + } + try { + const response = await fetch(`/api/guilds/${guildId}/feeds/${feed.id}`, { method: 'DELETE' }); + if (!response.ok) { + toast.error(t('common.saveError')); + return; + } + setFeeds((prev) => prev.filter((entry) => entry.clientKey !== feed.clientKey)); + toast.success(t('common.saveSuccess')); + } catch { + toast.error(t('common.saveError')); + } + } + + return ( + + + {t('modulePages.feeds.title')} + {t('modulePages.feeds.description')} + + + {feeds.length === 0 &&

{t('modulePages.feeds.empty')}

} + {feeds.map((feed) => ( +
+
+
+

+ {feed.type} 路 {feed.sourceId} +

+

+ #{feed.channelId} + {feed.rolePingId ? ` 路 @${feed.rolePingId}` : ''} +

+
+
+ handleToggle(feed)} /> + +
+
+
+ ))} + +
+

{t('modulePages.feeds.addFeed')}

+
+
+ + +
+
+ + setDraft((prev) => ({ ...prev, sourceId: event.target.value }))} + placeholder={t('modulePages.feeds.sourceIdPlaceholder')} + /> +
+
+ + setDraft((prev) => ({ ...prev, channelId: event.target.value }))} + placeholder="123456789012345678" + /> +
+
+ + setDraft((prev) => ({ ...prev, rolePingId: event.target.value }))} + placeholder={t('common.optional')} + /> +
+
+ + setDraft((prev) => ({ ...prev, template: event.target.value }))} + placeholder={t('modulePages.feeds.templatePlaceholder')} + /> +
+
+ +
+
+
+ ); +} diff --git a/apps/webui/src/components/modules/suggestions-config-form.tsx b/apps/webui/src/components/modules/suggestions-config-form.tsx new file mode 100644 index 0000000..0d4aa07 --- /dev/null +++ b/apps/webui/src/components/modules/suggestions-config-form.tsx @@ -0,0 +1,86 @@ +'use client'; + +import type { SuggestionConfigDashboard } from '@nexumi/shared'; +import { useId } from 'react'; +import { useTranslations } from '@/components/locale-provider'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import type { SettingsSaveResult } from '@/components/settings/settings-form'; +import { SettingsForm } from '@/components/settings/settings-form'; + +async function saveSuggestionsConfig( + guildId: string, + value: SuggestionConfigDashboard +): Promise { + try { + const response = await fetch(`/api/guilds/${guildId}/suggestions`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(value) + }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { error?: string } | null; + return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` }; + } + return { ok: true }; + } catch { + return { ok: false, error: 'Network error' }; + } +} + +interface SuggestionsConfigFormProps { + guildId: string; + initialValue: SuggestionConfigDashboard; +} + +export function SuggestionsConfigForm({ guildId, initialValue }: SuggestionsConfigFormProps) { + const t = useTranslations(); + const openId = useId(); + const approvedId = useId(); + const deniedId = useId(); + + return ( + + initialValue={initialValue} + onSave={(value) => saveSuggestionsConfig(guildId, value)} + > + {({ value, setValue }) => ( + + + {t('modulePages.suggestions.configTitle')} + {t('modulePages.suggestions.configDescription')} + + +
+ + setValue((prev) => ({ ...prev, enabled }))} /> +
+
+
+ + setValue((prev) => ({ ...prev, openChannelId: event.target.value.trim() }))} placeholder="123456789012345678" /> +
+
+ + setValue((prev) => ({ ...prev, approvedChannelId: event.target.value.trim() }))} placeholder={t('common.optional')} /> +
+
+ + setValue((prev) => ({ ...prev, deniedChannelId: event.target.value.trim() }))} placeholder={t('common.optional')} /> +
+
+
+
+ +

{t('modulePages.suggestions.createThreadHint')}

+
+ setValue((prev) => ({ ...prev, createThread }))} /> +
+
+
+ )} + + ); +} diff --git a/apps/webui/src/components/modules/suggestions-list-manager.tsx b/apps/webui/src/components/modules/suggestions-list-manager.tsx new file mode 100644 index 0000000..7d4ef12 --- /dev/null +++ b/apps/webui/src/components/modules/suggestions-list-manager.tsx @@ -0,0 +1,178 @@ +'use client'; + +import type { SuggestionAction, SuggestionDashboard } from '@nexumi/shared'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { useTranslations } from '@/components/locale-provider'; +import { Badge, type BadgeProps } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; + +const STATUS_VARIANTS: Record = { + OPEN: 'secondary', + APPROVED: 'success', + DENIED: 'destructive', + CONSIDERED: 'outline', + IMPLEMENTED: 'success' +}; + +const STATUS_FILTERS: Array = [ + 'ALL', + 'OPEN', + 'APPROVED', + 'DENIED', + 'CONSIDERED', + 'IMPLEMENTED' +]; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); +} + +interface SuggestionsListManagerProps { + guildId: string; + initialSuggestions: SuggestionDashboard[]; +} + +export function SuggestionsListManager({ guildId, initialSuggestions }: SuggestionsListManagerProps) { + const t = useTranslations(); + const [suggestions, setSuggestions] = useState(initialSuggestions); + const [statusFilter, setStatusFilter] = useState('ALL'); + const [reasons, setReasons] = useState>({}); + const [pendingId, setPendingId] = useState(null); + const [loading, setLoading] = useState(false); + + async function reload(nextStatus: SuggestionDashboard['status'] | 'ALL') { + setStatusFilter(nextStatus); + setLoading(true); + try { + const query = nextStatus === 'ALL' ? '' : `?status=${nextStatus}`; + const response = await fetch(`/api/guilds/${guildId}/suggestions/items${query}`); + const body = (await response.json().catch(() => null)) as { suggestions?: SuggestionDashboard[] } | null; + if (response.ok && body?.suggestions) { + setSuggestions(body.suggestions); + } + } finally { + setLoading(false); + } + } + + async function handleAction(suggestion: SuggestionDashboard, status: SuggestionAction['status']) { + setPendingId(suggestion.id); + try { + const response = await fetch(`/api/guilds/${guildId}/suggestions/items/${suggestion.id}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status, staffReason: reasons[suggestion.id] || null }) + }); + const body = (await response.json().catch(() => null)) as (SuggestionDashboard & { error?: string }) | null; + if (!response.ok || !body) { + toast.error(body?.error ?? t('common.saveError')); + return; + } + setSuggestions((prev) => prev.map((entry) => (entry.id === suggestion.id ? body : entry))); + toast.success(t('common.saveSuccess')); + } catch { + toast.error(t('common.saveError')); + } finally { + setPendingId(null); + } + } + + return ( + + +
+ {t('modulePages.suggestions.listTitle')} + {t('modulePages.suggestions.listDescription')} +
+ +
+ + {loading &&

{t('common.loading')}

} + {!loading && suggestions.length === 0 && ( +

{t('modulePages.suggestions.empty')}

+ )} + {!loading && + suggestions.map((suggestion) => ( +
+
+
+

{suggestion.content}

+

+ <@{suggestion.authorId}> 路 馃憤 {suggestion.upvotes} 路 馃憥 {suggestion.downvotes} 路{' '} + {formatDate(suggestion.createdAt)} +

+
+ + {t(`modulePages.suggestions.status.${suggestion.status}`)} + +
+ {suggestion.staffReason && ( +

+ {t('modulePages.suggestions.staffReason')}: {suggestion.staffReason} +

+ )} +