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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user