From d3b38e1063f578aa7e99b67fb3fba0093d7a9414 Mon Sep 17 00:00:00 2001
From: Randall Stillwell
Date: Thu, 28 May 2026 09:16:18 -0500
Subject: [PATCH] Add admin panel button to trigger catalog sync.
Expose POST /api/admin/sync-catalog for authenticated admins (import rate limit, 300s timeout) and wire a Run catalog sync control on /admin/card-import.
Co-authored-by: Cursor
---
pages/admin/card-import.js | 114 ++++++++++++++++++++++++++++++++
pages/api/admin/sync-catalog.js | 31 +++++++++
vercel.json | 3 +
3 files changed, 148 insertions(+)
create mode 100644 pages/api/admin/sync-catalog.js
diff --git a/pages/admin/card-import.js b/pages/admin/card-import.js
index 843dfe1..5fe5f10 100644
--- a/pages/admin/card-import.js
+++ b/pages/admin/card-import.js
@@ -10,6 +10,41 @@ const CardImport = () => {
const [setCode, setSetCode] = useState('');
const [isImporting, setIsImporting] = useState(false);
const [importResult, setImportResult] = useState(null);
+ const [isSyncing, setIsSyncing] = useState(false);
+ const [syncResult, setSyncResult] = useState(null);
+
+ const handleCatalogSync = async () => {
+ setIsSyncing(true);
+ setSyncResult(null);
+
+ try {
+ const response = await fetch('/api/admin/sync-catalog', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
+ },
+ });
+
+ const result = await response.json();
+
+ if (response.ok) {
+ setSyncResult({ success: true, ...result });
+ } else {
+ setSyncResult({
+ success: false,
+ message: result.error || 'Catalog sync failed',
+ });
+ }
+ } catch (error) {
+ setSyncResult({
+ success: false,
+ message: `Network error: ${error.message}`,
+ });
+ } finally {
+ setIsSyncing(false);
+ }
+ };
const handleImport = async () => {
if (!setCode.trim()) {
@@ -130,6 +165,85 @@ const CardImport = () => {
+ {/* Catalog sync */}
+
+
+
+
+ Catalog sync
+
+
+ Import up to three newest missing MTG and Pokémon sets — the same job the weekly Vercel cron runs.
+ Use this to backfill recent releases without curl.
+
+
+
+ {isSyncing ? 'Syncing catalog…' : 'Run catalog sync'}
+
+
+
+ {syncResult && (
+
+
+ {syncResult.success ? 'Catalog sync complete' : 'Catalog sync failed'}
+
+ {syncResult.success ? (
+
+
+ Imported {syncResult.imported} cards ({syncResult.skipped} skipped).
+
+ {(syncResult.pendingMtgSets != null || syncResult.pendingPokemonSets != null) && (
+
+ Still pending: {syncResult.pendingMtgSets} MTG sets,{' '}
+ {syncResult.pendingPokemonSets} Pokémon sets.
+
+ )}
+ {syncResult.lorcana &&
{syncResult.lorcana}
}
+ {Array.isArray(syncResult.setsProcessed) && syncResult.setsProcessed.length > 0 && (
+
+ {syncResult.setsProcessed.map((set) => (
+
+ {set.name} ({set.game}/{set.setCode}) — {set.imported} imported
+
+ ))}
+
+ )}
+ {Array.isArray(syncResult.errors) && syncResult.errors.length > 0 && (
+
+ {syncResult.errors.map((entry) => (
+
+ {entry.game}/{entry.setCode}: {entry.message}
+
+ ))}
+
+ )}
+
+ ) : (
+
{syncResult.message}
+ )}
+
+ )}
+
+
{/* Import Form */}
diff --git a/pages/api/admin/sync-catalog.js b/pages/api/admin/sync-catalog.js
new file mode 100644
index 0000000..e246178
--- /dev/null
+++ b/pages/api/admin/sync-catalog.js
@@ -0,0 +1,31 @@
+import { getUserFromRequest } from '../../../lib/permission-middleware';
+import { checkImportRateLimit } from '../../../lib/rate-limit.js';
+import { runCatalogSync } from '../../../lib/card-import/sync-catalog.js';
+
+export default async function handler(req, res) {
+ if (req.method !== 'POST') {
+ return res.status(405).json({ error: 'Method not allowed' });
+ }
+
+ const user = await getUserFromRequest(req);
+ if (!user) {
+ return res.status(401).json({ error: 'Authentication required' });
+ }
+ if (user.role !== 'admin') {
+ return res.status(403).json({ error: 'Admin access required' });
+ }
+
+ const { allowed, reset } = await checkImportRateLimit(req, user.userId);
+ if (!allowed) {
+ res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000));
+ return res.status(429).json({ error: 'Too many attempts. Try again later.' });
+ }
+
+ try {
+ const summary = await runCatalogSync();
+ return res.status(200).json({ success: true, ...summary });
+ } catch (error) {
+ console.error('[POST /api/admin/sync-catalog]', error);
+ return res.status(500).json({ error: 'Catalog sync failed', details: error.message });
+ }
+}
diff --git a/vercel.json b/vercel.json
index 398cd19..258f564 100644
--- a/vercel.json
+++ b/vercel.json
@@ -9,6 +9,9 @@
"functions": {
"pages/api/cron/sync-catalog.js": {
"maxDuration": 300
+ },
+ "pages/api/admin/sync-catalog.js": {
+ "maxDuration": 300
}
}
}