TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1'use server';23import { cookies } from 'next/headers';4import { redirect } from 'next/navigation';5import { revalidatePath } from 'next/cache';6import { z } from 'zod';7import { getDb, connectors, connectorRuns, connectorBackfills, sales, normalizedRecords, taxonomyProposals, categories, auditLog, events, eq, and, inArray, sql } from '@rareindex/database';8import { newId } from '@rareindex/shared';9import { ADMIN_COOKIE, adminCookieValue, isAdmin, tokenMatches } from './auth';10import { enqueue, QUEUES } from './queue';1112async function guard(): Promise<void> {13 if (!(await isAdmin())) throw new Error('Admin authentication required');14}1516async function audit(entityType: string, entityId: string, action: string, reason: string, details: Record<string, unknown> = {}) {17 await getDb().insert(auditLog).values({ id: newId('event'), entityType, entityId, action, reason, actor: 'admin', details });18}1920export async function adminLogin(formData: FormData): Promise<void> {21 const token = String(formData.get('token') ?? '');22 const next = String(formData.get('next') ?? '/admin');23 if (!process.env.ADMIN_TOKEN) redirect('/admin/login?error=ADMIN_TOKEN+is+not+set+on+the+server');24 if (!tokenMatches(token)) redirect('/admin/login?error=Invalid+token');25 const jar = await cookies();26 jar.set(ADMIN_COOKIE, adminCookieValue()!, { httpOnly: true, sameSite: 'strict', secure: process.env.NODE_ENV === 'production', path: '/', maxAge: 60 * 60 * 12 });27 redirect(next.startsWith('/admin') ? next : '/admin');28}2930export async function adminLogout(): Promise<void> {31 const jar = await cookies();32 jar.delete(ADMIN_COOKIE);33 redirect('/admin/login');34}3536const ConnectorAction = z.enum(['run', 'probe', 'recrawl', 'backfill', 'backfill_pause', 'backfill_reset', 'retry', 'pause', 'resume', 'maintenance']);3738/**39 * Connector control-center actions (§144, SPEC §29): run/probe/backfill enqueue `crawl.run`;40 * pause/resume/maintenance flip status; backfill_pause/backfill_reset manage the resumable campaign.41 */42export async function connectorAction(formData: FormData): Promise<void> {43 await guard();44 const id = String(formData.get('id') ?? '');45 const action = ConnectorAction.parse(formData.get('action'));46 const db = getDb();47 const [c] = await db.select().from(connectors).where(eq(connectors.id, id)).limit(1);48 if (!c) throw new Error(`unknown connector ${id}`);49 if (action === 'pause' || action === 'resume' || action === 'maintenance') {50 await db.update(connectors).set({ status: action === 'pause' ? 'paused' : action === 'maintenance' ? 'maintenance' : 'active', updatedAt: new Date() }).where(eq(connectors.id, id));51 await audit('connector', id, action, `admin ${action}`);52 } else if (action === 'backfill_pause') {53 await db.update(connectorBackfills).set({ status: 'paused', updatedAt: new Date() }).where(and(eq(connectorBackfills.connectorId, id), eq(connectorBackfills.status, 'running')));54 await audit('connector', id, action, 'admin paused backfill campaign');55 } else if (action === 'backfill_reset') {56 await db.update(connectorBackfills).set({ status: 'failed', lastError: 'reset by admin', finishedAt: new Date(), updatedAt: new Date() }).where(and(eq(connectorBackfills.connectorId, id), inArray(connectorBackfills.status, ['running', 'paused'])));57 await audit('connector', id, action, 'admin reset backfill campaign');58 } else {59 const mode = action === 'recrawl' || action === 'backfill' ? 'backfill' : action === 'probe' ? 'probe' : 'incremental';60 const payload: Record<string, unknown> = { connectorId: id, mode, trigger: action === 'retry' ? 'retry' : 'manual', requestedAt: new Date().toISOString() };61 if (action === 'probe') payload.limit = 25;62 if (action === 'retry') {63 const [last] = await db.select().from(connectorRuns).where(eq(connectorRuns.connectorId, id)).orderBy(sql`started_at desc`).limit(1);64 if (last) payload.cursor = last.cursor;65 }66 const jobId = await enqueue(QUEUES.crawlRun, payload, { singletonKey: `${id}:${mode}`, priority: 10 });67 await db.insert(events).values({ id: newId('event'), type: 'crawl_requested', entityType: 'connector', entityId: id, payload: { ...payload, jobId } });68 await audit('connector', id, action, `admin enqueued crawl.run (${mode})`, { jobId });69 }70 revalidatePath('/admin/connectors');71 revalidatePath(`/admin/connectors/${id}`);72}7374export async function updateConnectorConfig(formData: FormData): Promise<void> {75 await guard();76 const id = String(formData.get('id') ?? '');77 const raw = String(formData.get('config') ?? '{}');78 let config: Record<string, unknown>;79 try {80 config = z.record(z.string(), z.unknown()).parse(JSON.parse(raw));81 } catch (err) {82 throw new Error(`config must be a JSON object: ${err instanceof Error ? err.message : String(err)}`);83 }84 const refresh = Number(formData.get('refresh') ?? NaN);85 const priority = String(formData.get('priority') ?? 'medium');86 await getDb()87 .update(connectors)88 .set({ config, ...(Number.isFinite(refresh) && refresh > 0 ? { refreshFrequencyMinutes: Math.round(refresh) } : {}), ...(['high', 'medium', 'low'].includes(priority) ? { priority } : {}), updatedAt: new Date() })89 .where(eq(connectors.id, id));90 await audit('connector', id, 'edited', 'admin updated config/schedule', { config, refresh, priority });91 revalidatePath(`/admin/connectors/${id}`);92}9394export async function saleStatusAction(formData: FormData): Promise<void> {95 await guard();96 const id = String(formData.get('id') ?? '');97 const status = z.enum(['valid', 'flagged', 'excluded']).parse(formData.get('status'));98 const reason = String(formData.get('reason') ?? '').trim() || `admin set status ${status}`;99 const db = getDb();100 const [s] = await db.select({ status: sales.status, flags: sales.flags }).from(sales).where(eq(sales.id, id)).limit(1);101 if (!s) throw new Error('sale not found');102 const flags = status === 'valid' ? s.flags.filter((f) => !f.startsWith('admin:')) : [...new Set([...s.flags, `admin:${status}`])];103 await db.update(sales).set({ status, flags }).where(eq(sales.id, id));104 await audit('sale', id, status === 'valid' ? 'restored' : status, reason, { from: s.status, to: status });105 revalidatePath('/admin/data-quality');106}107108export async function manualMatchAction(formData: FormData): Promise<void> {109 await guard();110 const recordId = String(formData.get('recordId') ?? '');111 const assetId = String(formData.get('assetId') ?? '');112 const db = getDb();113 if (!assetId) {114 await db.update(normalizedRecords).set({ status: 'rejected', rejectReason: 'admin: rejected', processedAt: new Date() }).where(eq(normalizedRecords.id, recordId));115 await audit('normalized_record', recordId, 'rejected', 'admin rejected unmatched record');116 } else {117 await db.update(normalizedRecords).set({ status: 'matched', assetId, matchMethod: 'manual', matchConfidence: 1, rejectReason: null, processedAt: null }).where(eq(normalizedRecords.id, recordId));118 await audit('normalized_record', recordId, 'merged', 'admin manual match', { assetId });119 await enqueue(QUEUES.normalize, { normalizedRecordId: recordId, reason: 'manual_match' }).catch(() => null);120 }121 revalidatePath('/admin/data-quality');122}123124export async function taxonomyDecision(formData: FormData): Promise<void> {125 await guard();126 const id = String(formData.get('id') ?? '');127 const decision = z.enum(['approved', 'rejected']).parse(formData.get('decision'));128 const db = getDb();129 const [p] = await db.select().from(taxonomyProposals).where(eq(taxonomyProposals.id, id)).limit(1);130 if (!p) throw new Error('proposal not found');131 if (decision === 'approved') {132 const parent = String(formData.get('parent') ?? p.parentSlug ?? '') || null;133 const [parentRow] = parent ? await db.select().from(categories).where(eq(categories.slug, parent)).limit(1) : [];134 const [{ n }] = (await db.execute(sql`select count(*)::int as n from categories`)) as unknown as [{ n: number }];135 await db136 .insert(categories)137 .values({138 slug: p.proposedSlug,139 parentSlug: parentRow?.slug ?? null,140 familySlug: parentRow?.familySlug ?? p.proposedSlug,141 name: p.name,142 level: parentRow ? parentRow.level + 1 : 0,143 phase: 3,144 conditionScale: parentRow?.conditionScale ?? 'general',145 graders: parentRow?.graders ?? [],146 indexTicker: parentRow?.indexTicker ?? null,147 sortOrder: n + 1,148 description: `Added from taxonomy proposal ${p.id}`,149 })150 .onConflictDoNothing();151 }152 await db.update(taxonomyProposals).set({ status: decision, decidedBy: 'admin', decidedAt: new Date().toISOString() }).where(eq(taxonomyProposals.id, id));153 await audit('taxonomy_proposal', id, decision, `admin ${decision} ${p.proposedSlug}`);154 revalidatePath('/admin/taxonomy');155}156157export async function proposeTaxonomyNode(formData: FormData): Promise<void> {158 await guard();159 const slug = String(formData.get('slug') ?? '').trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_');160 const name = String(formData.get('name') ?? '').trim();161 const parent = String(formData.get('parent') ?? '').trim() || null;162 if (!slug || !name) throw new Error('slug and name required');163 await getDb().insert(taxonomyProposals).values({ id: newId('event').replace('evt_', 'tp_'), proposedSlug: slug, name, parentSlug: parent, evidence: { source: 'admin' } });164 revalidatePath('/admin/taxonomy');165}166