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:
167
src/app/admin/articles/[id]/page.tsx
Normal file
167
src/app/admin/articles/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
src/app/admin/articles/page.tsx
Normal file
175
src/app/admin/articles/page.tsx
Normal 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
179
src/app/admin/keys/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
602
src/app/admin/rules/page.tsx
Normal file
602
src/app/admin/rules/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
src/app/admin/settings/page.tsx
Normal file
150
src/app/admin/settings/page.tsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/app/admin/sources/page.tsx
Normal file
98
src/app/admin/sources/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/app/api/admin/articles/[id]/route.ts
Normal file
72
src/app/api/admin/articles/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/api/admin/articles/route.ts
Normal file
37
src/app/api/admin/articles/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
37
src/app/api/admin/keys/[id]/route.ts
Normal file
37
src/app/api/admin/keys/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/app/api/admin/keys/route.ts
Normal file
49
src/app/api/admin/keys/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
45
src/app/api/admin/rules/[id]/route.ts
Normal file
45
src/app/api/admin/rules/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/app/api/admin/rules/[id]/run/route.ts
Normal file
35
src/app/api/admin/rules/[id]/run/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/api/admin/rules/[id]/runs/route.ts
Normal file
22
src/app/api/admin/rules/[id]/runs/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
70
src/app/api/admin/rules/route.ts
Normal file
70
src/app/api/admin/rules/route.ts
Normal 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);
|
||||||
|
}
|
||||||
32
src/app/api/admin/settings/password/route.ts
Normal file
32
src/app/api/admin/settings/password/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
32
src/app/api/admin/sources/[id]/route.ts
Normal file
32
src/app/api/admin/sources/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/app/api/admin/sources/route.ts
Normal file
10
src/app/api/admin/sources/route.ts
Normal 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
6
src/instrumentation.ts
Normal 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
71
src/lib/rules.ts
Normal 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
40
src/lib/scheduler.ts
Normal 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user