SPB Git

spb/rareindex

Public
8.3 KB · 157 lines typescript
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
Files · d93392862678cbf30e175697fcb812bc4ff0b5a7
Menu

Theme