SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
9.3 KB · 166 lines typescript
Raw Blame History
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