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, sales, normalizedRecords, taxonomyProposals, categories, auditLog, events, eq, 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', 'retry', 'pause', 'resume']);3738/** Connector control-center actions (§144): run/recrawl/probe enqueue `crawl.run`; pause/resume flip status. */39export async function connectorAction(formData: FormData): Promise<void> {40 await guard();41 const id = String(formData.get('id') ?? '');42 const action = ConnectorAction.parse(formData.get('action'));43 const db = getDb();44 const [c] = await db.select().from(connectors).where(eq(connectors.id, id)).limit(1);45 if (!c) throw new Error(`unknown connector ${id}`);46 if (action === 'pause' || action === 'resume') {47 await db.update(connectors).set({ status: action === 'pause' ? 'paused' : 'active', updatedAt: new Date() }).where(eq(connectors.id, id));48 await audit('connector', id, action, `admin ${action}`);49 } else {50 const mode = action === 'recrawl' ? 'backfill' : action === 'probe' ? 'probe' : 'incremental';51 const payload: Record<string, unknown> = { connectorId: id, mode, trigger: action === 'retry' ? 'retry' : 'manual', requestedAt: new Date().toISOString() };52 if (action === 'probe') payload.limit = 25;53 if (action === 'retry') {54 const [last] = await db.select().from(connectorRuns).where(eq(connectorRuns.connectorId, id)).orderBy(sql`started_at desc`).limit(1);55 if (last) payload.cursor = last.cursor;56 }57 const jobId = await enqueue(QUEUES.crawlRun, payload, { singletonKey: `${id}:${mode}`, priority: 10 });58 await db.insert(events).values({ id: newId('event'), type: 'crawl_requested', entityType: 'connector', entityId: id, payload: { ...payload, jobId } });59 await audit('connector', id, action, `admin enqueued crawl.run (${mode})`, { jobId });60 }61 revalidatePath('/admin/connectors');62 revalidatePath(`/admin/connectors/${id}`);63}6465export async function updateConnectorConfig(formData: FormData): Promise<void> {66 await guard();67 const id = String(formData.get('id') ?? '');68 const raw = String(formData.get('config') ?? '{}');69 let config: Record<string, unknown>;70 try {71 config = z.record(z.string(), z.unknown()).parse(JSON.parse(raw));72 } catch (err) {73 throw new Error(`config must be a JSON object: ${err instanceof Error ? err.message : String(err)}`);74 }75 const refresh = Number(formData.get('refresh') ?? NaN);76 const priority = String(formData.get('priority') ?? 'medium');77 await getDb()78 .update(connectors)79 .set({ config, ...(Number.isFinite(refresh) && refresh > 0 ? { refreshFrequencyMinutes: Math.round(refresh) } : {}), ...(['high', 'medium', 'low'].includes(priority) ? { priority } : {}), updatedAt: new Date() })80 .where(eq(connectors.id, id));81 await audit('connector', id, 'edited', 'admin updated config/schedule', { config, refresh, priority });82 revalidatePath(`/admin/connectors/${id}`);83}8485export async function saleStatusAction(formData: FormData): Promise<void> {86 await guard();87 const id = String(formData.get('id') ?? '');88 const status = z.enum(['valid', 'flagged', 'excluded']).parse(formData.get('status'));89 const reason = String(formData.get('reason') ?? '').trim() || `admin set status ${status}`;90 const db = getDb();91 const [s] = await db.select({ status: sales.status, flags: sales.flags }).from(sales).where(eq(sales.id, id)).limit(1);92 if (!s) throw new Error('sale not found');93 const flags = status === 'valid' ? s.flags.filter((f) => !f.startsWith('admin:')) : [...new Set([...s.flags, `admin:${status}`])];94 await db.update(sales).set({ status, flags }).where(eq(sales.id, id));95 await audit('sale', id, status === 'valid' ? 'restored' : status, reason, { from: s.status, to: status });96 revalidatePath('/admin/data-quality');97}9899export async function manualMatchAction(formData: FormData): Promise<void> {100 await guard();101 const recordId = String(formData.get('recordId') ?? '');102 const assetId = String(formData.get('assetId') ?? '');103 const db = getDb();104 if (!assetId) {105 await db.update(normalizedRecords).set({ status: 'rejected', rejectReason: 'admin: rejected', processedAt: new Date() }).where(eq(normalizedRecords.id, recordId));106 await audit('normalized_record', recordId, 'rejected', 'admin rejected unmatched record');107 } else {108 await db.update(normalizedRecords).set({ status: 'matched', assetId, matchMethod: 'manual', matchConfidence: 1, rejectReason: null, processedAt: null }).where(eq(normalizedRecords.id, recordId));109 await audit('normalized_record', recordId, 'merged', 'admin manual match', { assetId });110 await enqueue(QUEUES.normalize, { normalizedRecordId: recordId, reason: 'manual_match' }).catch(() => null);111 }112 revalidatePath('/admin/data-quality');113}114115export async function taxonomyDecision(formData: FormData): Promise<void> {116 await guard();117 const id = String(formData.get('id') ?? '');118 const decision = z.enum(['approved', 'rejected']).parse(formData.get('decision'));119 const db = getDb();120 const [p] = await db.select().from(taxonomyProposals).where(eq(taxonomyProposals.id, id)).limit(1);121 if (!p) throw new Error('proposal not found');122 if (decision === 'approved') {123 const parent = String(formData.get('parent') ?? p.parentSlug ?? '') || null;124 const [parentRow] = parent ? await db.select().from(categories).where(eq(categories.slug, parent)).limit(1) : [];125 const [{ n }] = (await db.execute(sql`select count(*)::int as n from categories`)) as unknown as [{ n: number }];126 await db127 .insert(categories)128 .values({129 slug: p.proposedSlug,130 parentSlug: parentRow?.slug ?? null,131 familySlug: parentRow?.familySlug ?? p.proposedSlug,132 name: p.name,133 level: parentRow ? parentRow.level + 1 : 0,134 phase: 3,135 conditionScale: parentRow?.conditionScale ?? 'general',136 graders: parentRow?.graders ?? [],137 indexTicker: parentRow?.indexTicker ?? null,138 sortOrder: n + 1,139 description: `Added from taxonomy proposal ${p.id}`,140 })141 .onConflictDoNothing();142 }143 await db.update(taxonomyProposals).set({ status: decision, decidedBy: 'admin', decidedAt: new Date().toISOString() }).where(eq(taxonomyProposals.id, id));144 await audit('taxonomy_proposal', id, decision, `admin ${decision} ${p.proposedSlug}`);145 revalidatePath('/admin/taxonomy');146}147148export async function proposeTaxonomyNode(formData: FormData): Promise<void> {149 await guard();150 const slug = String(formData.get('slug') ?? '').trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_');151 const name = String(formData.get('name') ?? '').trim();152 const parent = String(formData.get('parent') ?? '').trim() || null;153 if (!slug || !name) throw new Error('slug and name required');154 await getDb().insert(taxonomyProposals).values({ id: newId('event').replace('evt_', 'tp_'), proposedSlug: slug, name, parentSlug: parent, evidence: { source: 'admin' } });155 revalidatePath('/admin/taxonomy');156}157