Add suggestion status update job and queue integration
- Introduced a new `SuggestionStatusUpdateJob` type and corresponding worker to handle suggestion status updates in the bot's job processing. - Added `suggestionsQueue` and `suggestionsQueueEvents` for managing suggestion-related jobs in both the bot and WebUI. - Implemented `runSuggestionStatusUpdateJob` to facilitate staff actions on suggestions via the WebUI, ensuring consistent behavior with existing commands. - Enhanced error handling for suggestion actions to improve user experience and reliability.
This commit is contained in:
@@ -19,6 +19,7 @@ import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/inde
|
|||||||
import { pollAllFeeds } from './modules/feeds/index.js';
|
import { pollAllFeeds } from './modules/feeds/index.js';
|
||||||
import { sendScheduledMessage } from './modules/scheduler/index.js';
|
import { sendScheduledMessage } from './modules/scheduler/index.js';
|
||||||
import { runGuildBackupRestoreJob } from './modules/guildbackup/index.js';
|
import { runGuildBackupRestoreJob } from './modules/guildbackup/index.js';
|
||||||
|
import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js';
|
||||||
import {
|
import {
|
||||||
automodQueue,
|
automodQueue,
|
||||||
automodQueueName,
|
automodQueueName,
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
moderationQueueName,
|
moderationQueueName,
|
||||||
reminderQueueName,
|
reminderQueueName,
|
||||||
scheduleQueueName,
|
scheduleQueueName,
|
||||||
|
suggestionsQueueName,
|
||||||
ticketQueue,
|
ticketQueue,
|
||||||
ticketQueueName,
|
ticketQueueName,
|
||||||
verificationQueueName
|
verificationQueueName
|
||||||
@@ -95,6 +97,13 @@ type BirthdayRoleExpireJob = {
|
|||||||
roleId: string;
|
roleId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SuggestionStatusUpdateJob = {
|
||||||
|
suggestionId: string;
|
||||||
|
guildId: string;
|
||||||
|
status: 'OPEN' | 'APPROVED' | 'DENIED' | 'CONSIDERED' | 'IMPLEMENTED';
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function startWorkers(context: BotContext): Worker[] {
|
export function startWorkers(context: BotContext): Worker[] {
|
||||||
const moderationWorker = new Worker<TempBanJob>(
|
const moderationWorker = new Worker<TempBanJob>(
|
||||||
moderationQueueName,
|
moderationQueueName,
|
||||||
@@ -281,6 +290,20 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
logger.error({ jobId: job?.id, error }, 'Guild backup restore job failed');
|
logger.error({ jobId: job?.id, error }, 'Guild backup restore job failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const suggestionsWorker = new Worker<SuggestionStatusUpdateJob>(
|
||||||
|
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 [
|
return [
|
||||||
moderationWorker,
|
moderationWorker,
|
||||||
backupWorker,
|
backupWorker,
|
||||||
@@ -293,7 +316,8 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
statsWorker,
|
statsWorker,
|
||||||
feedsWorker,
|
feedsWorker,
|
||||||
scheduleWorker,
|
scheduleWorker,
|
||||||
guildBackupWorker
|
guildBackupWorker,
|
||||||
|
suggestionsWorker
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export { suggestionsCommands } from './commands.js';
|
export { suggestionsCommands } from './commands.js';
|
||||||
export { isSuggestionButton, handleSuggestionButton } from './buttons.js';
|
export { isSuggestionButton, handleSuggestionButton } from './buttons.js';
|
||||||
|
export { runSuggestionStatusUpdateJob } from './service.js';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { SuggestionStatusSchema, t, tf, type SuggestionStatus } from '@nexumi/sh
|
|||||||
import type { Locale } from '@nexumi/shared';
|
import type { Locale } from '@nexumi/shared';
|
||||||
import { ensureGuild } from '../../guild.js';
|
import { ensureGuild } from '../../guild.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
|
|
||||||
export const SUGGESTION_VOTE_PREFIX = 'sug:vote:';
|
export const SUGGESTION_VOTE_PREFIX = 'sug:vote:';
|
||||||
@@ -339,3 +340,24 @@ export async function updateSuggestionStatus(
|
|||||||
await notifyAuthor(context, updated, locale, params.status, params.reason);
|
await notifyAuthor(context, updated, locale, params.status, params.reason);
|
||||||
return updated;
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,3 +25,5 @@ export const scheduleQueueName = 'schedules';
|
|||||||
export const scheduleQueue = new Queue(scheduleQueueName, { connection: redis });
|
export const scheduleQueue = new Queue(scheduleQueueName, { connection: redis });
|
||||||
export const guildBackupQueueName = 'guild-backups';
|
export const guildBackupQueueName = 'guild-backups';
|
||||||
export const guildBackupQueue = new Queue(guildBackupQueueName, { connection: redis });
|
export const guildBackupQueue = new Queue(guildBackupQueueName, { connection: redis });
|
||||||
|
export const suggestionsQueueName = 'suggestions';
|
||||||
|
export const suggestionsQueue = new Queue(suggestionsQueueName, { connection: redis });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/webui/src/app/api/guilds/[guildId]/feeds/route.ts
Normal file
44
apps/webui/src/app/api/guilds/[guildId]/feeds/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { SuggestionActionSchema } from '@nexumi/shared';
|
|||||||
import { type NextRequest, NextResponse } from 'next/server';
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||||
import { writeDashboardAudit } from '@/lib/audit';
|
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';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
@@ -29,6 +29,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
return NextResponse.json(after);
|
return NextResponse.json(after);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof SuggestionActionTimeoutError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 504 });
|
||||||
|
}
|
||||||
return toApiErrorResponse(error);
|
return toApiErrorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
apps/webui/src/app/dashboard/[guildId]/feeds/page.tsx
Normal file
22
apps/webui/src/app/dashboard/[guildId]/feeds/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'modules.feeds.label')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'modules.feeds.description')}</p>
|
||||||
|
</div>
|
||||||
|
<FeedsManager guildId={guildId} initialFeeds={feeds} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function SuggestionsLoading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-80" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-64 rounded-lg" />
|
||||||
|
<Skeleton className="h-96 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/webui/src/app/dashboard/[guildId]/suggestions/page.tsx
Normal file
28
apps/webui/src/app/dashboard/[guildId]/suggestions/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'modules.suggestions.label')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'modules.suggestions.description')}</p>
|
||||||
|
</div>
|
||||||
|
<SuggestionsConfigForm guildId={guildId} initialValue={config} />
|
||||||
|
<SuggestionsListManager guildId={guildId} initialSuggestions={suggestions} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
214
apps/webui/src/components/modules/feeds-manager.tsx
Normal file
214
apps/webui/src/components/modules/feeds-manager.tsx
Normal file
@@ -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<LocalFeed[]>(
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('modulePages.feeds.title')}</CardTitle>
|
||||||
|
<CardDescription>{t('modulePages.feeds.description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{feeds.length === 0 && <p className="text-sm text-muted-foreground">{t('modulePages.feeds.empty')}</p>}
|
||||||
|
{feeds.map((feed) => (
|
||||||
|
<div key={feed.clientKey} className="space-y-3 rounded-md border border-border p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{feed.type} · {feed.sourceId}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
#{feed.channelId}
|
||||||
|
{feed.rolePingId ? ` · @${feed.rolePingId}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={feed.enabled} disabled={feed.saving} onCheckedChange={() => handleToggle(feed)} />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t('common.remove')}
|
||||||
|
onClick={() => handleDelete(feed)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-md border border-dashed border-border p-4">
|
||||||
|
<p className="text-sm font-medium">{t('modulePages.feeds.addFeed')}</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.feeds.type')}</Label>
|
||||||
|
<Select value={draft.type} onValueChange={(type) => setDraft((prev) => ({ ...prev, type: type as SocialFeedDashboard['type'] }))}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FEED_TYPES.map((type) => (
|
||||||
|
<SelectItem key={type} value={type}>
|
||||||
|
{type}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.feeds.sourceId')}</Label>
|
||||||
|
<Input
|
||||||
|
value={draft.sourceId}
|
||||||
|
onChange={(event) => setDraft((prev) => ({ ...prev, sourceId: event.target.value }))}
|
||||||
|
placeholder={t('modulePages.feeds.sourceIdPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.feeds.channelId')}</Label>
|
||||||
|
<Input
|
||||||
|
value={draft.channelId}
|
||||||
|
onChange={(event) => setDraft((prev) => ({ ...prev, channelId: event.target.value }))}
|
||||||
|
placeholder="123456789012345678"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.feeds.rolePingId')}</Label>
|
||||||
|
<Input
|
||||||
|
value={draft.rolePingId}
|
||||||
|
onChange={(event) => setDraft((prev) => ({ ...prev, rolePingId: event.target.value }))}
|
||||||
|
placeholder={t('common.optional')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-2">
|
||||||
|
<Label>{t('modulePages.feeds.template')}</Label>
|
||||||
|
<Input
|
||||||
|
value={draft.template}
|
||||||
|
onChange={(event) => setDraft((prev) => ({ ...prev, template: event.target.value }))}
|
||||||
|
placeholder={t('modulePages.feeds.templatePlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" onClick={handleCreate} disabled={creating}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
{t('modulePages.feeds.addFeed')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<SettingsSaveResult> {
|
||||||
|
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 (
|
||||||
|
<SettingsForm<SuggestionConfigDashboard>
|
||||||
|
initialValue={initialValue}
|
||||||
|
onSave={(value) => saveSuggestionsConfig(guildId, value)}
|
||||||
|
>
|
||||||
|
{({ value, setValue }) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('modulePages.suggestions.configTitle')}</CardTitle>
|
||||||
|
<CardDescription>{t('modulePages.suggestions.configDescription')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
|
<Label>{t('modulePages.suggestions.enabledLabel')}</Label>
|
||||||
|
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={openId}>{t('modulePages.suggestions.openChannelId')}</Label>
|
||||||
|
<Input id={openId} value={value.openChannelId} onChange={(event) => setValue((prev) => ({ ...prev, openChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={approvedId}>{t('modulePages.suggestions.approvedChannelId')}</Label>
|
||||||
|
<Input id={approvedId} value={value.approvedChannelId} onChange={(event) => setValue((prev) => ({ ...prev, approvedChannelId: event.target.value.trim() }))} placeholder={t('common.optional')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={deniedId}>{t('modulePages.suggestions.deniedChannelId')}</Label>
|
||||||
|
<Input id={deniedId} value={value.deniedChannelId} onChange={(event) => setValue((prev) => ({ ...prev, deniedChannelId: event.target.value.trim() }))} placeholder={t('common.optional')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>{t('modulePages.suggestions.createThread')}</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('modulePages.suggestions.createThreadHint')}</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={value.createThread} onCheckedChange={(createThread) => setValue((prev) => ({ ...prev, createThread }))} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</SettingsForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
apps/webui/src/components/modules/suggestions-list-manager.tsx
Normal file
178
apps/webui/src/components/modules/suggestions-list-manager.tsx
Normal file
@@ -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<SuggestionDashboard['status'], BadgeProps['variant']> = {
|
||||||
|
OPEN: 'secondary',
|
||||||
|
APPROVED: 'success',
|
||||||
|
DENIED: 'destructive',
|
||||||
|
CONSIDERED: 'outline',
|
||||||
|
IMPLEMENTED: 'success'
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_FILTERS: Array<SuggestionDashboard['status'] | 'ALL'> = [
|
||||||
|
'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<SuggestionDashboard[]>(initialSuggestions);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<SuggestionDashboard['status'] | 'ALL'>('ALL');
|
||||||
|
const [reasons, setReasons] = useState<Record<string, string>>({});
|
||||||
|
const [pendingId, setPendingId] = useState<string | null>(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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{t('modulePages.suggestions.listTitle')}</CardTitle>
|
||||||
|
<CardDescription>{t('modulePages.suggestions.listDescription')}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Select value={statusFilter} onValueChange={(status) => reload(status as SuggestionDashboard['status'] | 'ALL')}>
|
||||||
|
<SelectTrigger className="w-full sm:w-48">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STATUS_FILTERS.map((status) => (
|
||||||
|
<SelectItem key={status} value={status}>
|
||||||
|
{status === 'ALL' ? t('modulePages.suggestions.statusAll') : t(`modulePages.suggestions.status.${status}`)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{loading && <p className="text-sm text-muted-foreground">{t('common.loading')}</p>}
|
||||||
|
{!loading && suggestions.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('modulePages.suggestions.empty')}</p>
|
||||||
|
)}
|
||||||
|
{!loading &&
|
||||||
|
suggestions.map((suggestion) => (
|
||||||
|
<div key={suggestion.id} className="space-y-3 rounded-md border border-border p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{suggestion.content}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<@{suggestion.authorId}> · 👍 {suggestion.upvotes} · 👎 {suggestion.downvotes} ·{' '}
|
||||||
|
{formatDate(suggestion.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={STATUS_VARIANTS[suggestion.status]}>
|
||||||
|
{t(`modulePages.suggestions.status.${suggestion.status}`)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{suggestion.staffReason && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('modulePages.suggestions.staffReason')}: {suggestion.staffReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Textarea
|
||||||
|
value={reasons[suggestion.id] ?? ''}
|
||||||
|
onChange={(event) => setReasons((prev) => ({ ...prev, [suggestion.id]: event.target.value }))}
|
||||||
|
placeholder={t('modulePages.suggestions.reasonPlaceholder')}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={pendingId === suggestion.id}
|
||||||
|
onClick={() => handleAction(suggestion, 'APPROVED')}
|
||||||
|
>
|
||||||
|
{t('modulePages.suggestions.approve')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={pendingId === suggestion.id}
|
||||||
|
onClick={() => handleAction(suggestion, 'DENIED')}
|
||||||
|
>
|
||||||
|
{t('modulePages.suggestions.deny')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={pendingId === suggestion.id}
|
||||||
|
onClick={() => handleAction(suggestion, 'CONSIDERED')}
|
||||||
|
>
|
||||||
|
{t('modulePages.suggestions.consider')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={pendingId === suggestion.id}
|
||||||
|
onClick={() => handleAction(suggestion, 'IMPLEMENTED')}
|
||||||
|
>
|
||||||
|
{t('modulePages.suggestions.implement')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { SocialFeedDashboard, SocialFeedDashboardCreate, SocialFeedDashboardUpdate } from '@nexumi/shared';
|
import type {
|
||||||
|
SocialFeedDashboard,
|
||||||
|
SocialFeedDashboardCreate,
|
||||||
|
SocialFeedDashboardUpdate
|
||||||
|
} from '@nexumi/shared';
|
||||||
import type { SocialFeed } from '@prisma/client';
|
import type { SocialFeed } from '@prisma/client';
|
||||||
import { prisma } from '../prisma';
|
import { prisma } from '../prisma';
|
||||||
|
|
||||||
@@ -14,12 +18,15 @@ function toDashboard(feed: SocialFeed): SocialFeedDashboard {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSocialFeeds(guildId: string): Promise<SocialFeedDashboard[]> {
|
export async function listFeeds(guildId: string): Promise<SocialFeedDashboard[]> {
|
||||||
const feeds = await prisma.socialFeed.findMany({ where: { guildId }, orderBy: { createdAt: 'asc' } });
|
const feeds = await prisma.socialFeed.findMany({
|
||||||
|
where: { guildId },
|
||||||
|
orderBy: { createdAt: 'asc' }
|
||||||
|
});
|
||||||
return feeds.map(toDashboard);
|
return feeds.map(toDashboard);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSocialFeed(
|
export async function createFeed(
|
||||||
guildId: string,
|
guildId: string,
|
||||||
input: SocialFeedDashboardCreate
|
input: SocialFeedDashboardCreate
|
||||||
): Promise<SocialFeedDashboard> {
|
): Promise<SocialFeedDashboard> {
|
||||||
@@ -30,7 +37,7 @@ export async function createSocialFeed(
|
|||||||
type: input.type,
|
type: input.type,
|
||||||
sourceId: input.sourceId,
|
sourceId: input.sourceId,
|
||||||
channelId: input.channelId,
|
channelId: input.channelId,
|
||||||
rolePingId: input.rolePingId ? input.rolePingId : null,
|
rolePingId: input.rolePingId || null,
|
||||||
template: input.template ?? null,
|
template: input.template ?? null,
|
||||||
enabled: input.enabled
|
enabled: input.enabled
|
||||||
}
|
}
|
||||||
@@ -38,7 +45,7 @@ export async function createSocialFeed(
|
|||||||
return toDashboard(feed);
|
return toDashboard(feed);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSocialFeed(
|
export async function updateFeed(
|
||||||
guildId: string,
|
guildId: string,
|
||||||
feedId: string,
|
feedId: string,
|
||||||
patch: SocialFeedDashboardUpdate
|
patch: SocialFeedDashboardUpdate
|
||||||
@@ -59,7 +66,11 @@ export async function updateSocialFeed(
|
|||||||
return toDashboard(updated);
|
return toDashboard(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSocialFeed(guildId: string, feedId: string): Promise<boolean> {
|
export async function deleteFeed(guildId: string, feedId: string): Promise<boolean> {
|
||||||
const result = await prisma.socialFeed.deleteMany({ where: { id: feedId, guildId } });
|
const existing = await prisma.socialFeed.findFirst({ where: { id: feedId, guildId } });
|
||||||
return result.count > 0;
|
if (!existing) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await prisma.socialFeed.delete({ where: { id: feedId } });
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import type {
|
|||||||
} from '@nexumi/shared';
|
} from '@nexumi/shared';
|
||||||
import type { Suggestion } from '@prisma/client';
|
import type { Suggestion } from '@prisma/client';
|
||||||
import { prisma } from '../prisma';
|
import { prisma } from '../prisma';
|
||||||
|
import { addJobAndAwait, suggestionsQueue, suggestionsQueueEvents } from '../queues';
|
||||||
|
|
||||||
|
export class SuggestionActionTimeoutError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('The bot did not confirm the suggestion update in time. Reload the list shortly to check the result.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureSuggestionConfig(guildId: string) {
|
async function ensureSuggestionConfig(guildId: string) {
|
||||||
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
||||||
@@ -53,7 +60,7 @@ export async function updateSuggestionConfigDashboard(
|
|||||||
return toConfigDashboard(updated);
|
return toConfigDashboard(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSuggestionDashboard(suggestion: Suggestion): SuggestionDashboard {
|
function toDashboard(suggestion: Suggestion): SuggestionDashboard {
|
||||||
return {
|
return {
|
||||||
id: suggestion.id,
|
id: suggestion.id,
|
||||||
authorId: suggestion.authorId,
|
authorId: suggestion.authorId,
|
||||||
@@ -75,25 +82,48 @@ export async function listSuggestions(
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: query.limit
|
take: query.limit
|
||||||
});
|
});
|
||||||
return suggestions.map(toSuggestionDashboard);
|
return suggestions.map(toDashboard);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff actions (approve/deny/consider/implement) need to move or edit the
|
||||||
|
* suggestion's Discord embed and DM the author, which requires the bot's
|
||||||
|
* discord.js Client. The WebUI enqueues a `suggestionStatusUpdate` job on
|
||||||
|
* the same `suggestions` BullMQ queue the bot worker consumes (see
|
||||||
|
* `apps/bot/src/modules/suggestions/service.ts#runSuggestionStatusUpdateJob`)
|
||||||
|
* and waits (bounded) for it to finish.
|
||||||
|
*/
|
||||||
export async function applySuggestionAction(
|
export async function applySuggestionAction(
|
||||||
guildId: string,
|
guildId: string,
|
||||||
suggestionId: string,
|
suggestionId: string,
|
||||||
action: SuggestionAction
|
action: SuggestionAction,
|
||||||
): Promise<SuggestionDashboard | null> {
|
_actorUserId: string
|
||||||
|
): Promise<SuggestionDashboard> {
|
||||||
const existing = await prisma.suggestion.findFirst({ where: { id: suggestionId, guildId } });
|
const existing = await prisma.suggestion.findFirst({ where: { id: suggestionId, guildId } });
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return null;
|
throw new SuggestionActionTimeoutError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.suggestion.update({
|
const { confirmed } = await addJobAndAwait<{ id: string }>(
|
||||||
where: { id: suggestionId },
|
suggestionsQueue,
|
||||||
data: {
|
suggestionsQueueEvents,
|
||||||
|
'suggestionStatusUpdate',
|
||||||
|
{
|
||||||
|
suggestionId,
|
||||||
|
guildId,
|
||||||
status: action.status,
|
status: action.status,
|
||||||
staffReason: action.staffReason ?? null
|
reason: action.staffReason ?? ''
|
||||||
|
},
|
||||||
|
{ removeOnComplete: true, removeOnFail: 25 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
throw new SuggestionActionTimeoutError();
|
||||||
}
|
}
|
||||||
});
|
|
||||||
return toSuggestionDashboard(updated);
|
const updated = await prisma.suggestion.findUnique({ where: { id: suggestionId } });
|
||||||
|
if (!updated) {
|
||||||
|
throw new SuggestionActionTimeoutError();
|
||||||
|
}
|
||||||
|
return toDashboard(updated);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,16 @@ import { redis } from './redis';
|
|||||||
export const giveawayQueueName = 'giveaways';
|
export const giveawayQueueName = 'giveaways';
|
||||||
export const scheduleQueueName = 'schedules';
|
export const scheduleQueueName = 'schedules';
|
||||||
export const guildBackupQueueName = 'guild-backups';
|
export const guildBackupQueueName = 'guild-backups';
|
||||||
|
export const suggestionsQueueName = 'suggestions';
|
||||||
|
|
||||||
const globalForQueues = globalThis as unknown as {
|
const globalForQueues = globalThis as unknown as {
|
||||||
__nexumiGiveawayQueue?: Queue;
|
__nexumiGiveawayQueue?: Queue;
|
||||||
__nexumiScheduleQueue?: Queue;
|
__nexumiScheduleQueue?: Queue;
|
||||||
__nexumiGuildBackupQueue?: Queue;
|
__nexumiGuildBackupQueue?: Queue;
|
||||||
|
__nexumiSuggestionsQueue?: Queue;
|
||||||
__nexumiGiveawayQueueEvents?: QueueEvents;
|
__nexumiGiveawayQueueEvents?: QueueEvents;
|
||||||
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
||||||
|
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const giveawayQueue: Queue =
|
export const giveawayQueue: Queue =
|
||||||
@@ -30,6 +33,9 @@ export const scheduleQueue: Queue =
|
|||||||
export const guildBackupQueue: Queue =
|
export const guildBackupQueue: Queue =
|
||||||
globalForQueues.__nexumiGuildBackupQueue ?? new Queue(guildBackupQueueName, { connection: redis });
|
globalForQueues.__nexumiGuildBackupQueue ?? new Queue(guildBackupQueueName, { connection: redis });
|
||||||
|
|
||||||
|
export const suggestionsQueue: Queue =
|
||||||
|
globalForQueues.__nexumiSuggestionsQueue ?? new Queue(suggestionsQueueName, { connection: redis });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QueueEvents instances are only needed for jobs where the dashboard needs
|
* QueueEvents instances are only needed for jobs where the dashboard needs
|
||||||
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
||||||
@@ -44,11 +50,17 @@ export const guildBackupQueueEvents: QueueEvents =
|
|||||||
globalForQueues.__nexumiGuildBackupQueueEvents ??
|
globalForQueues.__nexumiGuildBackupQueueEvents ??
|
||||||
new QueueEvents(guildBackupQueueName, { connection: redis.duplicate() });
|
new QueueEvents(guildBackupQueueName, { connection: redis.duplicate() });
|
||||||
|
|
||||||
|
export const suggestionsQueueEvents: QueueEvents =
|
||||||
|
globalForQueues.__nexumiSuggestionsQueueEvents ??
|
||||||
|
new QueueEvents(suggestionsQueueName, { connection: redis.duplicate() });
|
||||||
|
|
||||||
globalForQueues.__nexumiGiveawayQueue = giveawayQueue;
|
globalForQueues.__nexumiGiveawayQueue = giveawayQueue;
|
||||||
globalForQueues.__nexumiScheduleQueue = scheduleQueue;
|
globalForQueues.__nexumiScheduleQueue = scheduleQueue;
|
||||||
globalForQueues.__nexumiGuildBackupQueue = guildBackupQueue;
|
globalForQueues.__nexumiGuildBackupQueue = guildBackupQueue;
|
||||||
|
globalForQueues.__nexumiSuggestionsQueue = suggestionsQueue;
|
||||||
globalForQueues.__nexumiGiveawayQueueEvents = giveawayQueueEvents;
|
globalForQueues.__nexumiGiveawayQueueEvents = giveawayQueueEvents;
|
||||||
globalForQueues.__nexumiGuildBackupQueueEvents = guildBackupQueueEvents;
|
globalForQueues.__nexumiGuildBackupQueueEvents = guildBackupQueueEvents;
|
||||||
|
globalForQueues.__nexumiSuggestionsQueueEvents = suggestionsQueueEvents;
|
||||||
|
|
||||||
const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
|
const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user