feat: admin article management, sources, settings, rules, and scheduler

- Add article list, editor, and deletion UI.
- Add source management with bias/credibility/active toggles.
- Add encrypted API key CRUD UI.
- Add automated search rules with query, sources, schedule presets,
  and per-rule AI model override.
- Implement node-cron scheduler and instrumentation hook.
- Add admin settings including password change and quota reference.
This commit is contained in:
hermes
2026-06-17 04:32:29 +00:00
parent 3e57802c6e
commit 8daac94f5a
20 changed files with 1929 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ArticleStatus, Bias } from '@prisma/client';
type Article = {
id: string;
title: string;
summary: string | null;
url: string;
primaryBias: Bias;
politicalTags: string;
rankScore: number;
credibilityScore: number;
status: ArticleStatus;
topics: string;
aiExplanation: string | null;
};
export default function EditArticlePage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const [article, setArticle] = useState<Article | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
fetch(`/api/admin/articles/${id}`)
.then((res) => res.json())
.then((data) => {
setArticle(data);
});
}, [id]);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!article) return;
setSaving(true);
const res = await fetch(`/api/admin/articles/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: article.title,
summary: article.summary,
primaryBias: article.primaryBias,
politicalTags: JSON.parse(article.politicalTags || '[]'),
rankScore: article.rankScore,
credibilityScore: article.credibilityScore,
status: article.status,
topics: JSON.parse(article.topics || '[]'),
}),
});
setSaving(false);
if (res.ok) {
router.push('/admin/articles');
} else {
alert('Failed to save');
}
}
if (!article) return <p>Loading...</p>;
return (
<div>
<h1 className="text-2xl font-bold mb-6">Edit Article</h1>
<form onSubmit={handleSave} className="bg-white dark:bg-neutral-900 rounded-xl shadow-sm border dark:border-neutral-800 p-6 space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Title</label>
<input
type="text"
value={article.title}
onChange={(e) => setArticle({ ...article, title: e.target.value })}
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Summary</label>
<textarea
value={article.summary || ''}
onChange={(e) => setArticle({ ...article, summary: e.target.value })}
rows={5}
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Bias</label>
<select
value={article.primaryBias}
onChange={(e) => setArticle({ ...article, primaryBias: e.target.value as Bias })}
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
>
<option value="LEFT">Left</option>
<option value="CENTER">Center</option>
<option value="RIGHT">Right</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select
value={article.status}
onChange={(e) => setArticle({ ...article, status: e.target.value as ArticleStatus })}
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
>
<option value="DRAFT">Draft</option>
<option value="PUBLISHED">Published</option>
<option value="ARCHIVED">Archived</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Rank Score</label>
<input
type="number"
min={1}
max={10}
step={0.1}
value={article.rankScore}
onChange={(e) => setArticle({ ...article, rankScore: Number(e.target.value) })}
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Political Tags (JSON array)</label>
<input
type="text"
value={article.politicalTags}
onChange={(e) => setArticle({ ...article, politicalTags: e.target.value })}
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Topics (JSON array)</label>
<input
type="text"
value={article.topics}
onChange={(e) => setArticle({ ...article, topics: e.target.value })}
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
/>
</div>
{article.aiExplanation && (
<div className="text-sm text-neutral-500 dark:text-neutral-400 bg-neutral-50 dark:bg-neutral-800 p-3 rounded-lg">
<strong>AI explanation:</strong> {article.aiExplanation}
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={saving}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button
type="button"
onClick={() => router.push('/admin/articles')}
className="px-4 py-2 border rounded-lg font-medium hover:bg-neutral-50 dark:hover:bg-neutral-800"
>
Cancel
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,175 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { ArticleStatus, Bias } from '@prisma/client';
import { Trash2, Edit } from 'lucide-react';
const biasColors: Record<Bias, string> = {
LEFT: 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300',
CENTER: 'bg-purple-100 text-purple-800 dark:bg-purple-950 dark:text-purple-300',
RIGHT: 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300',
};
const statusColors: Record<ArticleStatus, string> = {
DRAFT: 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400',
PUBLISHED: 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300',
ARCHIVED: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-950 dark:text-yellow-300',
};
type Article = {
id: string;
title: string;
summary: string | null;
url: string;
primaryBias: Bias;
status: ArticleStatus;
rankScore: number;
publishedAt: string | null;
source: { name: string };
};
export default function AdminArticlesPage() {
const [articles, setArticles] = useState<Article[]>([]);
const [total, setTotal] = useState(0);
const [filters, setFilters] = useState({
status: '',
bias: '',
q: '',
});
async function fetchArticles() {
const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status);
if (filters.bias) params.set('bias', filters.bias);
if (filters.q) params.set('q', filters.q);
const res = await fetch(`/api/admin/articles?${params.toString()}`);
if (res.ok) {
const data = await res.json();
setArticles(data.articles);
setTotal(data.total);
}
}
useEffect(() => {
fetchArticles();
}, [filters]);
async function deleteArticle(id: string) {
if (!confirm('Delete this article?')) return;
const res = await fetch(`/api/admin/articles/${id}`, { method: 'DELETE' });
if (res.ok) fetchArticles();
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">Articles</h1>
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-sm border dark:border-neutral-800 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<input
type="text"
placeholder="Search title/summary"
value={filters.q}
onChange={(e) => setFilters({ ...filters, q: e.target.value })}
className="px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
/>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
>
<option value="">All statuses</option>
<option value="DRAFT">Draft</option>
<option value="PUBLISHED">Published</option>
<option value="ARCHIVED">Archived</option>
</select>
<select
value={filters.bias}
onChange={(e) => setFilters({ ...filters, bias: e.target.value })}
className="px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
>
<option value="">All biases</option>
<option value="LEFT">Left</option>
<option value="CENTER">Center</option>
<option value="RIGHT">Right</option>
</select>
</div>
</div>
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
{total} articles found
</p>
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-sm border dark:border-neutral-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800">
<tr>
<th className="px-4 py-3 text-left">Title</th>
<th className="px-4 py-3 text-left">Bias</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-left">Rank</th>
<th className="px-4 py-3 text-left">Date</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{articles.map((a) => (
<tr key={a.id} className="border-t dark:border-neutral-800">
<td className="px-4 py-3">
<a
href={a.url}
target="_blank"
rel="noopener noreferrer"
className="font-medium hover:underline line-clamp-2"
>
{a.title}
</a>
<div className="text-xs text-neutral-500">{a.source.name}</div>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${biasColors[a.primaryBias]}`}>
{a.primaryBias}
</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusColors[a.status]}`}>
{a.status}
</span>
</td>
<td className="px-4 py-3">{a.rankScore}</td>
<td className="px-4 py-3 text-neutral-500 dark:text-neutral-400">
{a.publishedAt ? new Date(a.publishedAt).toLocaleDateString() : '-'}
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-2">
<Link
href={`/admin/articles/${a.id}`}
className="p-2 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg"
>
<Edit size={16} />
</Link>
<button
onClick={() => deleteArticle(a.id)}
className="p-2 hover:bg-red-50 dark:hover:bg-red-950 text-red-600 rounded-lg"
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
))}
{articles.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-neutral-500">
No articles found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}

179
src/app/admin/keys/page.tsx Normal file
View File

@@ -0,0 +1,179 @@
'use client';
import { useEffect, useState } from 'react';
import { ApiProvider } from '@prisma/client';
type ApiKeyRecord = {
id: string;
provider: ApiProvider;
label: string | null;
isActive: boolean;
lastUsedAt: string | null;
createdAt: string;
maskedKey: string;
};
const providers: ApiProvider[] = ['NEWSAPI', 'GNEWS', 'NEWSDATA', 'KIMI', 'WEBSEARCH', 'BRAVE'];
export default function ApiKeysPage() {
const [keys, setKeys] = useState<ApiKeyRecord[]>([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState({ provider: 'KIMI' as ApiProvider, key: '', label: '' });
async function fetchKeys() {
const res = await fetch('/api/admin/keys');
if (res.ok) setKeys(await res.json());
setLoading(false);
}
useEffect(() => {
fetchKeys();
}, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const res = await fetch('/api/admin/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
if (res.ok) {
setForm({ provider: 'KIMI', key: '', label: '' });
fetchKeys();
} else {
const data = await res.json();
alert(data.error || 'Failed to add key');
}
}
async function toggleActive(id: string, current: boolean) {
const res = await fetch(`/api/admin/keys/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: !current }),
});
if (res.ok) fetchKeys();
}
async function removeKey(id: string) {
if (!confirm('Delete this API key?')) return;
const res = await fetch(`/api/admin/keys/${id}`, { method: 'DELETE' });
if (res.ok) fetchKeys();
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">API Keys</h1>
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-sm border dark:border-neutral-800 p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Add New Key</h2>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
<div>
<label className="block text-sm font-medium mb-1">Provider</label>
<select
value={form.provider}
onChange={(e) => setForm({ ...form, provider: e.target.value as ApiProvider })}
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
>
{providers.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1">Key</label>
<input
type="password"
value={form.key}
onChange={(e) => setForm({ ...form, key: e.target.value })}
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
placeholder="Paste key here"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Label</label>
<input
type="text"
value={form.label}
onChange={(e) => setForm({ ...form, label: e.target.value })}
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
placeholder="Optional"
/>
</div>
<div className="md:col-span-4">
<button
type="submit"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
>
Add Key
</button>
</div>
</form>
</div>
{loading ? (
<p>Loading...</p>
) : (
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-sm border dark:border-neutral-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800">
<tr>
<th className="px-4 py-3 text-left">Provider</th>
<th className="px-4 py-3 text-left">Label</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-left">Added</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{keys.map((k) => (
<tr key={k.id} className="border-t dark:border-neutral-800">
<td className="px-4 py-3 font-medium">{k.provider}</td>
<td className="px-4 py-3">{k.label || '-'}</td>
<td className="px-4 py-3">
<span
className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${
k.isActive
? 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300'
: 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400'
}`}
>
{k.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-4 py-3 text-neutral-500 dark:text-neutral-400">
{new Date(k.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-3 text-right space-x-2">
<button
onClick={() => toggleActive(k.id, k.isActive)}
className="text-blue-600 hover:underline"
>
{k.isActive ? 'Disable' : 'Enable'}
</button>
<button
onClick={() => removeKey(k.id)}
className="text-red-600 hover:underline"
>
Delete
</button>
</td>
</tr>
))}
{keys.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-neutral-500">
No API keys configured yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,602 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
type SearchRun = {
id: string;
status: 'RUNNING' | 'SUCCESS' | 'ERROR';
stage: string | null;
progress: string | null;
articlesFound: number;
error: string | null;
startedAt: string;
endedAt: string | null;
};
type SearchRule = {
id: string;
name: string;
query: string;
sources: string;
schedule: string;
maxResults: number;
autoPublish: boolean;
minRankScore: number;
isActive: boolean;
aiProvider: string;
aiModel: string | null;
lastRunAt: string | null;
_count: { runs: number };
runs: SearchRun[];
};
const availableSources = [
{ code: 'newsapi', name: 'NewsAPI' },
{ code: 'gnews', name: 'GNews' },
{ code: 'newsdata', name: 'NewsData' },
{ code: 'websearch', name: 'Web Search (Serper)' },
{ code: 'brave', name: 'Brave Search' },
];
const availableAiProviders = [
{ code: 'KIMI', name: 'Kimi (Moonshot)' },
];
const aiModelPresets = [
{ code: '', name: 'Use default (from KIMI_MODEL env)' },
{ code: 'kimi-k2.5', name: 'kimi-k2.5' },
{ code: 'kimi-k2.6', name: 'kimi-k2.6' },
{ code: 'kimi-k2.7-code', name: 'kimi-k2.7-code' },
{ code: 'kimi-k2.7-code-highspeed', name: 'kimi-k2.7-code-highspeed' },
{ code: 'moonshot-v1-8k', name: 'moonshot-v1-8k' },
{ code: 'moonshot-v1-32k', name: 'moonshot-v1-32k' },
{ code: 'moonshot-v1-128k', name: 'moonshot-v1-128k' },
{ code: 'custom', name: 'Other / custom' },
];
const schedulePresets = [
{ code: '*/15 * * * *', name: 'Every 15 minutes' },
{ code: '*/30 * * * *', name: 'Every 30 minutes' },
{ code: '0 * * * *', name: 'Every hour' },
{ code: '0 */6 * * *', name: 'Every 6 hours' },
{ code: '0 */12 * * *', name: 'Every 12 hours' },
{ code: '0 0 * * *', name: 'Daily at midnight' },
{ code: '0 0 * * 0', name: 'Weekly on Sunday' },
{ code: 'custom', name: 'Custom cron' },
];
function describeCron(cron: string): string {
const map: Record<string, string> = {
'*/15 * * * *': 'Every 15 minutes',
'*/30 * * * *': 'Every 30 minutes',
'0 * * * *': 'Every hour',
'0 */6 * * *': 'Every 6 hours',
'0 */12 * * *': 'Every 12 hours',
'0 0 * * *': 'Daily at midnight',
'0 0 * * 0': 'Weekly on Sunday',
};
return map[cron.trim()] || `Custom schedule: ${cron}`;
}
export default function RulesPage() {
const [rules, setRules] = useState<SearchRule[]>([]);
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState<Record<string, string>>({});
const [editingId, setEditingId] = useState<string | null>(null);
const [kimiStatus, setKimiStatus] = useState<{ configured: boolean; label?: string } | null>(null);
const [form, setForm] = useState({
name: '',
query: '',
sources: [] as string[],
schedule: '0 */6 * * *',
schedulePreset: '0 */6 * * *',
scheduleCustom: '',
maxResults: 10,
autoPublish: false,
minRankScore: 5,
aiProvider: 'KIMI',
aiModelPreset: '',
aiModelCustom: '',
});
async function fetchRules() {
const res = await fetch('/api/admin/rules');
if (res.ok) {
const data = await res.json();
setRules(data);
// Resume polling for any runs still marked RUNNING (e.g. after page refresh).
const stillRunning: Record<string, string> = {};
for (const rule of data) {
const activeRun = rule.runs.find((r: SearchRun) => r.status === 'RUNNING');
if (activeRun) {
stillRunning[rule.id] = activeRun.id;
pollRun(rule.id, activeRun.id);
}
}
setRunning((prev) => ({ ...prev, ...stillRunning }));
}
setLoading(false);
}
async function fetchKimiStatus() {
const res = await fetch('/api/admin/keys');
if (!res.ok) return;
const keys = await res.json();
const kimi = keys.find((k: { provider: string; isActive: boolean }) => k.provider === 'KIMI' && k.isActive);
setKimiStatus(kimi ? { configured: true, label: kimi.label || 'Kimi key' } : { configured: false });
}
useEffect(() => {
fetchRules();
fetchKimiStatus();
}, []);
const toggleSource = (code: string) => {
setForm((prev) => ({
...prev,
sources: prev.sources.includes(code)
? prev.sources.filter((c) => c !== code)
: [...prev.sources, code],
}));
};
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const aiModel = form.aiModelPreset === 'custom'
? form.aiModelCustom.trim()
: form.aiModelPreset;
const schedule = form.schedulePreset === 'custom'
? form.scheduleCustom.trim()
: form.schedulePreset;
if (!schedule) {
alert('Please enter a custom cron schedule');
return;
}
const payload = {
name: form.name,
query: form.query,
sources: form.sources,
schedule,
maxResults: form.maxResults,
autoPublish: form.autoPublish,
minRankScore: form.minRankScore,
aiProvider: form.aiProvider,
aiModel,
};
const url = editingId ? `/api/admin/rules/${editingId}` : '/api/admin/rules';
const method = editingId ? 'PATCH' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) {
cancelEdit();
fetchRules();
} else {
const data = await res.json();
alert(data.error || `Failed to ${editingId ? 'update' : 'create'} rule`);
}
}
async function runRule(id: string) {
const res = await fetch(`/api/admin/rules/${id}/run`, { method: 'POST' });
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Rule run failed');
return;
}
const { runId } = await res.json();
setRunning((prev) => ({ ...prev, [id]: runId }));
pollRun(id, runId);
}
async function pollRun(ruleId: string, runId: string) {
const poll = async () => {
const res = await fetch(`/api/admin/rules/${ruleId}/runs`);
if (!res.ok) return;
const data = await res.json();
const run = data.runs.find((r: SearchRun) => r.id === runId);
if (!run) return;
if (run.status === 'SUCCESS' || run.status === 'ERROR') {
setRunning((prev) => {
const next = { ...prev };
delete next[ruleId];
return next;
});
fetchRules();
} else {
setTimeout(poll, 2000);
}
};
poll();
}
async function toggleActive(rule: SearchRule) {
const res = await fetch(`/api/admin/rules/${rule.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: !rule.isActive }),
});
if (res.ok) fetchRules();
}
async function deleteRule(id: string) {
if (!confirm('Delete this rule?')) return;
const res = await fetch(`/api/admin/rules/${id}`, { method: 'DELETE' });
if (res.ok) fetchRules();
}
function editRule(rule: SearchRule) {
const schedulePreset = schedulePresets.find((p) => p.code === rule.schedule)
? rule.schedule
: 'custom';
const aiModelPreset = aiModelPresets.find((m) => m.code === (rule.aiModel || ''))
? rule.aiModel || ''
: 'custom';
setEditingId(rule.id);
setForm({
name: rule.name,
query: rule.query,
sources: JSON.parse(rule.sources || '[]') as string[],
schedule: rule.schedule,
schedulePreset,
scheduleCustom: schedulePreset === 'custom' ? rule.schedule : '',
maxResults: rule.maxResults,
autoPublish: rule.autoPublish,
minRankScore: rule.minRankScore,
aiProvider: rule.aiProvider,
aiModelPreset,
aiModelCustom: aiModelPreset === 'custom' ? rule.aiModel || '' : '',
});
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function cancelEdit() {
setEditingId(null);
setForm({
name: '',
query: '',
sources: [],
schedule: '0 */6 * * *',
schedulePreset: '0 */6 * * *',
scheduleCustom: '',
maxResults: 10,
autoPublish: false,
minRankScore: 5,
aiProvider: 'KIMI',
aiModelPreset: '',
aiModelCustom: '',
});
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">Automated Search Rules</h1>
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-sm border dark:border-neutral-800 p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">
{editingId ? 'Edit Rule' : 'Create Rule'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<input
type="text"
placeholder="Rule name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
required
/>
<input
type="text"
placeholder="Search query"
value={form.query}
onChange={(e) => setForm({ ...form, query: e.target.value })}
className="px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Sources</label>
<div className="flex flex-wrap gap-3">
{availableSources.map((s) => (
<label
key={s.code}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer ${
form.sources.includes(s.code)
? 'bg-blue-50 border-blue-300 dark:bg-blue-950 dark:border-blue-700'
: 'bg-white dark:bg-neutral-900 border-neutral-200 dark:border-neutral-700'
}`}
>
<input
type="checkbox"
checked={form.sources.includes(s.code)}
onChange={() => toggleSource(s.code)}
/>
<span className="text-sm">{s.name}</span>
</label>
))}
</div>
</div>
<div className="bg-neutral-50 dark:bg-neutral-800/50 rounded-lg p-4 border dark:border-neutral-700">
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-semibold">AI Analysis</label>
{kimiStatus && (
<span
className={`text-xs px-2 py-1 rounded-full font-medium ${
kimiStatus.configured
? 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300'
: 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300'
}`}
>
{kimiStatus.configured ? `Kimi key configured (${kimiStatus.label})` : 'Kimi key missing'}
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs text-neutral-500 dark:text-neutral-400 mb-1">Provider</label>
<select
value={form.aiProvider}
onChange={(e) => setForm({ ...form, aiProvider: e.target.value })}
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
>
{availableAiProviders.map((p) => (
<option key={p.code} value={p.code}>
{p.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-neutral-500 dark:text-neutral-400 mb-1">Model</label>
<select
value={form.aiModelPreset}
onChange={(e) => setForm({ ...form, aiModelPreset: e.target.value })}
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
>
{aiModelPresets.map((m) => (
<option key={m.code} value={m.code}>
{m.name}
</option>
))}
</select>
{form.aiModelPreset === 'custom' && (
<input
type="text"
placeholder="e.g. moonshot-v1-32k"
value={form.aiModelCustom}
onChange={(e) => setForm({ ...form, aiModelCustom: e.target.value })}
className="w-full mt-2 px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
/>
)}
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Schedule</label>
<select
value={form.schedulePreset}
onChange={(e) => setForm({ ...form, schedulePreset: e.target.value })}
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
>
{schedulePresets.map((p) => (
<option key={p.code} value={p.code}>
{p.name}
</option>
))}
</select>
{form.schedulePreset === 'custom' && (
<input
type="text"
placeholder="e.g. 0 9 * * 1-5"
value={form.scheduleCustom}
onChange={(e) => setForm({ ...form, scheduleCustom: e.target.value })}
className="w-full mt-2 px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
/>
)}
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{describeCron(form.schedulePreset === 'custom' ? form.scheduleCustom : form.schedulePreset)}
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Max Results</label>
<input
type="number"
min={1}
max={50}
value={form.maxResults}
onChange={(e) => setForm({ ...form, maxResults: Number(e.target.value) })}
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Min Rank Score</label>
<input
type="number"
min={1}
max={10}
step={0.1}
value={form.minRankScore}
onChange={(e) => setForm({ ...form, minRankScore: Number(e.target.value) })}
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={form.autoPublish}
onChange={(e) => setForm({ ...form, autoPublish: e.target.checked })}
/>
<span className="text-sm font-medium">Auto-publish</span>
</label>
</div>
</div>
<div className="flex items-center gap-3">
<button
type="submit"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
>
{editingId ? 'Update Rule' : 'Create Rule'}
</button>
{editingId && (
<button
type="button"
onClick={cancelEdit}
className="px-4 py-2 text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800 rounded-lg font-medium"
>
Cancel
</button>
)}
</div>
</form>
</div>
{loading ? (
<p>Loading...</p>
) : (
<div className="space-y-4">
{rules.map((rule) => (
<div
key={rule.id}
className="bg-white dark:bg-neutral-900 rounded-xl shadow-sm border dark:border-neutral-800 p-5"
>
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-lg">{rule.name}</h3>
{running[rule.id] && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-950 dark:text-yellow-300">
<Loader2 size={12} className="animate-spin" />
Running
</span>
)}
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Query: <span className="font-medium text-neutral-700 dark:text-neutral-300">{rule.query}</span>
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => runRule(rule.id)}
disabled={!!running[rule.id]}
className="inline-flex items-center gap-2 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg text-sm font-medium"
>
{running[rule.id] ? (
<>
<Loader2 size={16} className="animate-spin" />
Running
</>
) : (
'Run Now'
)}
</button>
<button
onClick={() => editRule(rule)}
className="px-3 py-1.5 bg-neutral-100 hover:bg-neutral-200 text-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:text-neutral-300 rounded-lg text-sm font-medium"
>
Edit
</button>
<button
onClick={() => toggleActive(rule)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium ${
rule.isActive
? 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300'
: 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400'
}`}
>
{rule.isActive ? 'Active' : 'Inactive'}
</button>
<button
onClick={() => deleteRule(rule.id)}
className="px-3 py-1.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-950 rounded-lg text-sm font-medium"
>
Delete
</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm mb-4">
<div>
<span className="text-neutral-500 dark:text-neutral-400">Schedule</span>
<div className="font-medium">{describeCron(rule.schedule)}</div>
<div className="font-mono text-xs text-neutral-400">{rule.schedule}</div>
</div>
<div>
<span className="text-neutral-500 dark:text-neutral-400">Max results</span>
<div>{rule.maxResults}</div>
</div>
<div>
<span className="text-neutral-500 dark:text-neutral-400">Min rank</span>
<div>{rule.minRankScore}</div>
</div>
<div>
<span className="text-neutral-500 dark:text-neutral-400">Auto-publish</span>
<div>{rule.autoPublish ? 'Yes' : 'No'}</div>
</div>
<div>
<span className="text-neutral-500 dark:text-neutral-400">AI model</span>
<div className="font-mono truncate" title={rule.aiModel || 'default'}>
{rule.aiModel || 'default'}
</div>
</div>
</div>
{rule.runs.length > 0 && (
<div className="border-t dark:border-neutral-800 pt-3">
<h4 className="text-sm font-medium mb-2">Recent runs</h4>
<div className="space-y-2">
{rule.runs.map((run) => (
<div
key={run.id}
className="flex flex-wrap gap-3 text-xs text-neutral-600 dark:text-neutral-400"
>
<span
className={`px-2 py-0.5 rounded-full font-medium ${
run.status === 'SUCCESS'
? 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300'
: run.status === 'ERROR'
? 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-950 dark:text-yellow-300'
}`}
>
{run.status}
</span>
<span>{run.articlesFound} articles</span>
{run.status === 'RUNNING' && run.stage && (
<span className="text-yellow-700 dark:text-yellow-300">
{run.stage}
{run.progress ? `${run.progress}` : ''}
</span>
)}
<span>{new Date(run.startedAt).toLocaleString()}</span>
{run.error && <span className="text-red-600">{run.error}</span>}
</div>
))}
</div>
</div>
)}
</div>
))}
{rules.length === 0 && (
<p className="text-neutral-500 dark:text-neutral-400">No rules yet.</p>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,150 @@
'use client';
import { useState } from 'react';
export default function SettingsPage() {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [message, setMessage] = useState('');
const [loading, setLoading] = useState(false);
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setMessage('');
const res = await fetch('/api/admin/settings/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword }),
});
const data = await res.json();
setLoading(false);
if (res.ok) {
setMessage('Password updated successfully.');
setCurrentPassword('');
setNewPassword('');
} else {
setMessage(data.error || 'Failed to update password.');
}
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">Settings</h1>
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-sm border dark:border-neutral-800 p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Change Admin Password</h2>
{message && (
<div className={`mb-4 p-3 rounded-lg text-sm ${message.includes('success') ? 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300' : 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300'}`}>
{message}
</div>
)}
<form onSubmit={handleChangePassword} className="space-y-4 max-w-md">
<div>
<label className="block text-sm font-medium mb-1">Current Password</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">New Password</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
required
minLength={6}
/>
</div>
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium disabled:opacity-50"
>
{loading ? 'Updating...' : 'Update Password'}
</button>
</form>
</div>
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-sm border dark:border-neutral-800 p-6 mb-6">
<h2 className="text-lg font-semibold mb-2">Environment</h2>
<ul className="text-sm text-neutral-600 dark:text-neutral-400 space-y-1">
<li>Admin username: {process.env.NEXT_PUBLIC_ADMIN_USERNAME || 'admin'}</li>
<li>AI endpoint: {process.env.KIMI_API_URL || 'https://api.moonshot.ai/v1'}</li>
<li>AI model: {process.env.KIMI_MODEL || 'kimi-k2.5'}</li>
</ul>
</div>
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-sm border dark:border-neutral-800 p-6">
<h2 className="text-lg font-semibold mb-2">Schedule &amp; API Quota Reference</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
Each active source counts as one query per rule run. For example, a rule using NewsData
+ GNews + Serper consumes 3 queries every time it runs.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm border dark:border-neutral-800">
<thead className="bg-neutral-50 dark:bg-neutral-800">
<tr>
<th className="px-4 py-2 text-left font-medium">Schedule</th>
<th className="px-4 py-2 text-left font-medium">Runs per rule/month</th>
<th className="px-4 py-2 text-left font-medium">Queries/month (1 source)</th>
</tr>
</thead>
<tbody className="divide-y dark:divide-neutral-800">
<tr>
<td className="px-4 py-2">Every 15 minutes</td>
<td className="px-4 py-2">~2,880</td>
<td className="px-4 py-2">~2,880</td>
</tr>
<tr>
<td className="px-4 py-2">Every 30 minutes</td>
<td className="px-4 py-2">~1,440</td>
<td className="px-4 py-2">~1,440</td>
</tr>
<tr>
<td className="px-4 py-2">Every hour</td>
<td className="px-4 py-2">~720</td>
<td className="px-4 py-2">~720</td>
</tr>
<tr>
<td className="px-4 py-2">Every 6 hours</td>
<td className="px-4 py-2">~120</td>
<td className="px-4 py-2">~120</td>
</tr>
<tr>
<td className="px-4 py-2">Every 12 hours</td>
<td className="px-4 py-2">~60</td>
<td className="px-4 py-2">~60</td>
</tr>
<tr>
<td className="px-4 py-2">Daily at midnight</td>
<td className="px-4 py-2">~30</td>
<td className="px-4 py-2">~30</td>
</tr>
</tbody>
</table>
</div>
<div className="mt-4 text-sm text-neutral-600 dark:text-neutral-400 space-y-2">
<p>
<strong>Serper free tier:</strong> 2,500 queries/month. With 2 rules running every
15 minutes, you would use ~5,760 queries/month and exceed the free quota.
</p>
<p>
<strong>Recommendation:</strong> use news APIs (NewsData, NewsAPI, GNews) as your
primary sources, and use Serper only as a fallback every 6-12 hours.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import { useEffect, useState } from 'react';
import { Bias } from '@prisma/client';
type Source = {
id: string;
name: string;
apiCode: string;
baseUrl: string | null;
active: boolean;
credibilityScore: number;
defaultBias: Bias | null;
_count: { articles: number };
};
export default function SourcesPage() {
const [sources, setSources] = useState<Source[]>([]);
async function fetchSources() {
const res = await fetch('/api/admin/sources');
if (res.ok) setSources(await res.json());
}
useEffect(() => {
fetchSources();
}, []);
async function updateSource(id: string, patch: Partial<Source>) {
const res = await fetch(`/api/admin/sources/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (res.ok) fetchSources();
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">News Sources</h1>
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-sm border dark:border-neutral-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800">
<tr>
<th className="px-4 py-3 text-left">Name</th>
<th className="px-4 py-3 text-left">API Code</th>
<th className="px-4 py-3 text-left">Default Bias</th>
<th className="px-4 py-3 text-left">Credibility</th>
<th className="px-4 py-3 text-left">Articles</th>
<th className="px-4 py-3 text-left">Active</th>
</tr>
</thead>
<tbody>
{sources.map((s) => (
<tr key={s.id} className="border-t dark:border-neutral-800">
<td className="px-4 py-3 font-medium">{s.name}</td>
<td className="px-4 py-3">{s.apiCode}</td>
<td className="px-4 py-3">
<select
value={s.defaultBias || ''}
onChange={(e) =>
updateSource(s.id, { defaultBias: (e.target.value as Bias) || null })
}
className="px-2 py-1 border rounded dark:bg-neutral-800 dark:border-neutral-700"
>
<option value="">None</option>
<option value="LEFT">Left</option>
<option value="CENTER">Center</option>
<option value="RIGHT">Right</option>
</select>
</td>
<td className="px-4 py-3">
<input
type="number"
min={1}
max={10}
step={0.1}
value={s.credibilityScore}
onChange={(e) => updateSource(s.id, { credibilityScore: Number(e.target.value) })}
className="w-20 px-2 py-1 border rounded dark:bg-neutral-800 dark:border-neutral-700"
/>
</td>
<td className="px-4 py-3">{s._count.articles}</td>
<td className="px-4 py-3">
<input
type="checkbox"
checked={s.active}
onChange={(e) => updateSource(s.id, { active: e.target.checked })}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { ArticleStatus, Bias, Prisma } from '@prisma/client';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const article = await prisma.article.findUnique({
where: { id },
include: { source: true },
});
if (!article) {
return NextResponse.json({ error: 'Article not found' }, { status: 404 });
}
return NextResponse.json(article);
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
const data: Prisma.ArticleUpdateInput = {};
if (body.title !== undefined) data.title = body.title;
if (body.summary !== undefined) data.summary = body.summary;
if (body.primaryBias !== undefined) {
if (!Object.values(Bias).includes(body.primaryBias)) {
return NextResponse.json({ error: 'Invalid bias' }, { status: 400 });
}
data.primaryBias = body.primaryBias;
}
if (body.politicalTags !== undefined) data.politicalTags = JSON.stringify(body.politicalTags);
if (body.topics !== undefined) data.topics = JSON.stringify(body.topics);
if (body.rankScore !== undefined) data.rankScore = Number(body.rankScore);
if (body.credibilityScore !== undefined) data.credibilityScore = Number(body.credibilityScore);
if (body.status !== undefined) {
if (!Object.values(ArticleStatus).includes(body.status)) {
return NextResponse.json({ error: 'Invalid status' }, { status: 400 });
}
data.status = body.status;
}
try {
const updated = await prisma.article.update({
where: { id },
data,
include: { source: true },
});
return NextResponse.json(updated);
} catch {
return NextResponse.json({ error: 'Article not found' }, { status: 404 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
await prisma.article.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: 'Article not found' }, { status: 404 });
}
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { ArticleStatus, Bias, Prisma } from '@prisma/client';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') as ArticleStatus | null;
const bias = searchParams.get('bias') as Bias | null;
const sourceId = searchParams.get('sourceId');
const query = searchParams.get('q');
const limit = Number(searchParams.get('limit') || '50');
const offset = Number(searchParams.get('offset') || '0');
const where: Prisma.ArticleWhereInput = {};
if (status) where.status = status;
if (bias) where.primaryBias = bias;
if (sourceId) where.sourceId = sourceId;
if (query) {
where.OR = [
{ title: { contains: query } },
{ summary: { contains: query } },
];
}
const [articles, total] = await Promise.all([
prisma.article.findMany({
where,
orderBy: [{ createdAt: 'desc' }],
take: limit,
skip: offset,
include: { source: true },
}),
prisma.article.count({ where }),
]);
return NextResponse.json({ articles, total });
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { deleteKey, updateKey } from '@/lib/api-keys';
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
const { key, label, isActive } = body;
try {
const updated = await updateKey(id, { key, label, isActive });
return NextResponse.json({
id: updated.id,
provider: updated.provider,
label: updated.label,
isActive: updated.isActive,
updatedAt: updated.updatedAt,
});
} catch {
return NextResponse.json({ error: 'Key not found' }, { status: 404 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
await deleteKey(id);
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: 'Key not found' }, { status: 404 });
}
}

View File

@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import { createKey, listKeys, maskKey } from '@/lib/api-keys';
import { ApiProvider } from '@prisma/client';
export async function GET() {
const keys = await listKeys();
return NextResponse.json(
keys.map((k) => ({
id: k.id,
provider: k.provider,
label: k.label,
isActive: k.isActive,
lastUsedAt: k.lastUsedAt,
createdAt: k.createdAt,
maskedKey: maskKey('****************'),
}))
);
}
export async function POST(request: Request) {
const body = await request.json();
const { provider, key, label, isActive } = body;
if (!provider || !key) {
return NextResponse.json(
{ error: 'Provider and key are required' },
{ status: 400 }
);
}
if (!Object.values(ApiProvider).includes(provider)) {
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
}
const created = await createKey({
provider,
key,
label,
isActive: isActive ?? true,
});
return NextResponse.json({
id: created.id,
provider: created.provider,
label: created.label,
isActive: created.isActive,
createdAt: created.createdAt,
});
}

View File

@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { scheduleJob } from '@/lib/scheduler';
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
const data: Record<string, unknown> = {};
if (body.name !== undefined) data.name = body.name;
if (body.query !== undefined) data.query = body.query;
if (body.sources !== undefined) data.sources = JSON.stringify(body.sources);
if (body.schedule !== undefined) data.schedule = body.schedule;
if (body.maxResults !== undefined) data.maxResults = Number(body.maxResults);
if (body.autoPublish !== undefined) data.autoPublish = body.autoPublish;
if (body.minRankScore !== undefined) data.minRankScore = Number(body.minRankScore);
if (body.isActive !== undefined) data.isActive = body.isActive;
if (body.aiProvider !== undefined) data.aiProvider = body.aiProvider;
if (body.aiModel !== undefined) data.aiModel = body.aiModel || null;
try {
const updated = await prisma.searchRule.update({ where: { id }, data });
await scheduleJob();
return NextResponse.json(updated);
} catch {
return NextResponse.json({ error: 'Rule not found' }, { status: 404 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
await prisma.searchRule.delete({ where: { id } });
await scheduleJob();
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: 'Rule not found' }, { status: 404 });
}
}

View File

@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { runSearchRule } from '@/lib/rules';
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const rule = await prisma.searchRule.findUnique({ where: { id } });
if (!rule) {
return NextResponse.json({ error: 'Rule not found' }, { status: 404 });
}
const run = await prisma.searchRun.create({
data: { ruleId: rule.id, status: 'RUNNING' },
});
// Start the work in the background so the HTTP request returns immediately.
// This avoids timeouts on long-running rules and lets the UI poll for status.
runSearchRule(rule, run.id).catch((err) => {
console.error('Background rule run failed:', err);
});
return NextResponse.json({ runId: run.id, status: 'RUNNING' });
} catch (error) {
console.error('Rule run error:', error);
return NextResponse.json(
{ error: String(error) },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const rule = await prisma.searchRule.findUnique({ where: { id } });
if (!rule) {
return NextResponse.json({ error: 'Rule not found' }, { status: 404 });
}
const runs = await prisma.searchRun.findMany({
where: { ruleId: id },
orderBy: { startedAt: 'desc' },
take: 5,
});
return NextResponse.json({ runs });
}

View File

@@ -0,0 +1,70 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { scheduleJob } from '@/lib/scheduler';
export async function GET() {
// Mark any run that has been stuck for more than 10 minutes as timed out.
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
await prisma.searchRun.updateMany({
where: {
status: 'RUNNING',
startedAt: { lt: tenMinutesAgo },
},
data: {
status: 'ERROR',
error: 'Timed out after running for more than 10 minutes',
endedAt: new Date(),
},
});
const rules = await prisma.searchRule.findMany({
orderBy: { createdAt: 'desc' },
include: {
_count: { select: { runs: true } },
runs: { orderBy: { startedAt: 'desc' }, take: 3 },
},
});
return NextResponse.json(rules);
}
export async function POST(request: Request) {
const body = await request.json();
const {
name,
query,
sources,
schedule,
maxResults,
autoPublish,
minRankScore,
isActive,
aiProvider,
aiModel,
} = body;
if (!name || !query) {
return NextResponse.json(
{ error: 'Name and query are required' },
{ status: 400 }
);
}
const rule = await prisma.searchRule.create({
data: {
name,
query,
sources: JSON.stringify(sources || []),
schedule: schedule || '0 */6 * * *',
maxResults: Number(maxResults || 20),
autoPublish: autoPublish ?? false,
minRankScore: Number(minRankScore ?? 5),
isActive: isActive ?? true,
aiProvider: aiProvider || 'KIMI',
aiModel: aiModel || null,
},
});
await scheduleJob();
return NextResponse.json(rule);
}

View File

@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import bcrypt from 'bcryptjs';
export async function POST(request: Request) {
const { currentPassword, newPassword } = await request.json();
if (!currentPassword || !newPassword || newPassword.length < 6) {
return NextResponse.json(
{ error: 'Current password and a new password of at least 6 characters are required' },
{ status: 400 }
);
}
const admin = await prisma.adminUser.findFirst();
if (!admin) {
return NextResponse.json({ error: 'Admin user not found' }, { status: 404 });
}
const valid = await bcrypt.compare(currentPassword, admin.passwordHash);
if (!valid) {
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 });
}
const newHash = await bcrypt.hash(newPassword, 10);
await prisma.adminUser.update({
where: { id: admin.id },
data: { passwordHash: newHash },
});
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { Bias } from '@prisma/client';
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
try {
const data: {
active?: boolean;
credibilityScore?: number;
defaultBias?: Bias | null;
} = {};
if (body.active !== undefined) data.active = body.active;
if (body.credibilityScore !== undefined) data.credibilityScore = Number(body.credibilityScore);
if (body.defaultBias !== undefined) {
data.defaultBias = body.defaultBias ? (body.defaultBias as Bias) : null;
}
const updated = await prisma.source.update({
where: { id },
data,
});
return NextResponse.json(updated);
} catch {
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
}
}

View File

@@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
export async function GET() {
const sources = await prisma.source.findMany({
orderBy: { name: 'asc' },
include: { _count: { select: { articles: true } } },
});
return NextResponse.json(sources);
}

6
src/instrumentation.ts Normal file
View File

@@ -0,0 +1,6 @@
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { scheduleJob } = await import('./lib/scheduler');
await scheduleJob();
}
}

71
src/lib/rules.ts Normal file
View File

@@ -0,0 +1,71 @@
import { prisma } from './db';
import { fetchNews } from './news';
import { processArticle } from './ai/processor';
import { saveProcessedArticle } from './articles';
import { SearchRule } from '@prisma/client';
async function updateRunStage(runId: string, stage: string, progress?: string) {
await prisma.searchRun.update({
where: { id: runId },
data: { stage, progress },
});
}
export async function runSearchRule(rule: SearchRule, existingRunId?: string) {
const run = existingRunId
? await prisma.searchRun.findUnique({ where: { id: existingRunId } })
: await prisma.searchRun.create({
data: { ruleId: rule.id, status: 'RUNNING' },
});
if (!run) throw new Error('Run not found');
try {
await updateRunStage(run.id, 'FETCHING', 'contacting sources');
const sources = JSON.parse(rule.sources || '[]') as string[];
const rawArticles = await fetchNews({
query: rule.query,
sources: sources.length > 0 ? sources : undefined,
maxResults: rule.maxResults,
});
let processedCount = 0;
const total = rawArticles.length;
for (let i = 0; i < rawArticles.length; i++) {
const raw = rawArticles[i];
await updateRunStage(run.id, 'ANALYZING', `${i + 1} of ${total}`);
try {
const analyzed = await processArticle(raw, {
model: rule.aiModel || undefined,
});
if (analyzed.rankScore >= rule.minRankScore) {
await updateRunStage(run.id, 'SAVING', `${i + 1} of ${total}`);
const status = rule.autoPublish ? 'PUBLISHED' : 'DRAFT';
await saveProcessedArticle(analyzed, status);
processedCount++;
}
} catch (err) {
console.error('Failed to process article in rule run:', err);
}
}
await prisma.searchRun.update({
where: { id: run.id },
data: { status: 'SUCCESS', articlesFound: processedCount, endedAt: new Date(), stage: null, progress: null },
});
await prisma.searchRule.update({
where: { id: rule.id },
data: { lastRunAt: new Date() },
});
return { success: true, articlesFound: processedCount };
} catch (err) {
await prisma.searchRun.update({
where: { id: run.id },
data: { status: 'ERROR', error: String(err), endedAt: new Date(), stage: null, progress: null },
});
throw err;
}
}

40
src/lib/scheduler.ts Normal file
View File

@@ -0,0 +1,40 @@
import cron, { ScheduledTask } from 'node-cron';
import { prisma } from './db';
import { runSearchRule } from './rules';
const scheduledTasks = new Map<string, ScheduledTask>();
export async function scheduleJob() {
// Clear existing schedules
for (const task of scheduledTasks.values()) {
task.stop();
}
scheduledTasks.clear();
const rules = await prisma.searchRule.findMany({
where: { isActive: true },
});
for (const rule of rules) {
if (!cron.validate(rule.schedule)) {
console.warn(`Invalid cron schedule for rule ${rule.id}: ${rule.schedule}`);
continue;
}
const task = cron.schedule(rule.schedule, async () => {
console.log(`Running scheduled rule: ${rule.name}`);
try {
await runSearchRule(rule);
} catch (err) {
console.error(`Scheduled rule ${rule.name} failed:`, err);
}
});
scheduledTasks.set(rule.id, task);
console.log(`Scheduled rule "${rule.name}" with cron "${rule.schedule}"`);
}
}
export async function reschedule() {
await scheduleJob();
}