Un build de admin interno es un problema de traducción antes que un problema de UI. El equipo ya lleva el negocio; el trabajo simplemente ocurre en las herramientas equivocadas. Las hojas de cálculo son lentas y sin versión; el SQL ad hoc es rápido y poco seguro; los hilos de Slack no sobreviven a las personas que los escribieron. La admin convierte esos workflows en acciones tipadas, auditadas, recortadas por rol, que todo el equipo puede usar sin perder la velocidad de hacerlas en una hoja.
Los cinco archivos de abajo son la armadura que deja el encargo. El middleware SSO que vigila la admin, el helper de query basado en roles que aplica RLS en cada lectura, el runner de bulk action con audit log, el wrapper de agente Claude, y el esquema admin que define qué puede tocar un rol admin.
1. El middleware SSO
Cada ruta admin corre detrás de SSO. El middleware lee la sesión, busca el rol del usuario en la base de datos, y deja pasar la request o devuelve 403. Sin endpoints públicos. Sin flujo de "olvidé mi contraseña". El identity provider del cliente es la fuente de verdad.
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import { getServerSession } from '@/lib/auth/session'
import { lookupAdminRole } from '@/lib/admin/roles'
const ALLOWED_DOMAINS = ['adamarant.com']
export async function middleware(req: NextRequest): Promise<NextResponse> {
const session = await getServerSession(req)
if (!session) {
return NextResponse.redirect(new URL('/auth/sign-in', req.url))
}
const email = session.user.email ?? ''
const domain = email.split('@')[1]
if (!ALLOWED_DOMAINS.includes(domain)) {
return new NextResponse('forbidden', { status: 403 })
}
const role = await lookupAdminRole(session.user.id)
if (!role) {
return new NextResponse('not provisioned', { status: 403 })
}
const requestHeaders = new Headers(req.headers)
requestHeaders.set('x-admin-role', role)
requestHeaders.set('x-admin-user-id', session.user.id)
return NextResponse.next({ request: { headers: requestHeaders } })
}
export const config = { matcher: ['/((?!auth|api/auth|_next).*)'] }
2. El helper de query basado en roles
El helper lee el rol admin de las cabeceras de la request, abre un cliente Supabase recortado a ese rol, y corre la query. La JWT de Supabase lleva el claim de rol; las policies de la base de datos lo aplican. El engineer de admin no se salta la RLS aunque quisiera.
// lib/admin/db.ts
import { createClient } from '@supabase/supabase-js'
import { headers } from 'next/headers'
import { signRoleJwt } from './role-jwt'
interface AdminContext {
role: 'admin' | 'ops' | 'support' | 'finance' | 'read_only'
userId: string
}
export async function adminContext(): Promise<AdminContext> {
const h = await headers()
const role = h.get('x-admin-role') as AdminContext['role'] | null
const userId = h.get('x-admin-user-id')
if (!role || !userId) throw new Error('missing admin context')
return { role, userId }
}
export async function adminDb(): Promise<ReturnType<typeof createClient>> {
const ctx = await adminContext()
const jwt = signRoleJwt({ sub: ctx.userId, role: ctx.role })
return createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, {
global: { headers: { Authorization: `Bearer ${jwt}` } },
})
}
3. El runner de bulk action con audit log
Una bulk action coge una lista de IDs, una función a aplicar por ID, y una justificación. Corre en lotes con control de concurrencia, captura errores por fila, y escribe una fila de audit antes y después de cada cambio. La UI muestra el progreso; el equipo cierra la pestaña y recibe un ping en Slack cuando el run termina.
// lib/admin/bulk.ts
import { adminContext, adminDb } from './db'
import { sendSlack } from '@/lib/integrations/slack'
interface BulkRunOptions<T> {
entityType: string
ids: string[]
justification: string
apply: (id: string, db: Awaited<ReturnType<typeof adminDb>>) => Promise<T>
concurrency?: number
}
export async function runBulk<T>(opts: BulkRunOptions<T>): Promise<{ ok: number; failed: number }> {
const ctx = await adminContext()
const db = await adminDb()
const concurrency = opts.concurrency ?? 5
let ok = 0
let failed = 0
for (let i = 0; i < opts.ids.length; i += concurrency) {
const batch = opts.ids.slice(i, i + concurrency)
const results = await Promise.allSettled(
batch.map(async (id) => {
const before = await db.from(opts.entityType).select('*').eq('id', id).single()
const next = await opts.apply(id, db)
const after = await db.from(opts.entityType).select('*').eq('id', id).single()
await db.from('audit_log').insert({
actor_user_id: ctx.userId,
actor_role: ctx.role,
entity_type: opts.entityType,
entity_id: id,
action: 'bulk_update',
before: before.data,
after: after.data,
justification: opts.justification,
})
return next
}),
)
for (const r of results) {
if (r.status === 'fulfilled') ok += 1
else failed += 1
}
}
await sendSlack({
channel: '#ops',
text: `bulk run on ${opts.entityType}: ${ok} ok, ${failed} failed (actor=${ctx.userId})`,
})
return { ok, failed }
}
4. El wrapper de agente Claude
El agente es una sola función. Coge una tarea, un set de tools y un contexto. Cada tool es un pequeño wrapper auditado alrededor de una acción admin. El agente elige tools, los llama, y escribe un transcript que el equipo puede leer. El agente nunca tiene más permisos de los que su rol permite.
// lib/admin/agent.ts
import Anthropic from '@anthropic-ai/sdk'
import { adminContext, adminDb } from './db'
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
interface AgentTool {
name: string
description: string
inputSchema: Record<string, unknown>
run: (input: Record<string, unknown>) => Promise<string>
}
export async function runAgent(
task: string,
tools: AgentTool[],
): Promise<{ transcript: string; result: string }> {
const ctx = await adminContext()
const db = await adminDb()
const transcript: string[] = []
const response = await anthropic.messages.create({
model: 'claude-opus-4-7',
max_tokens: 4096,
messages: [{ role: 'user', content: task }],
tools: tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.inputSchema as Anthropic.Tool.InputSchema })),
})
// Por brevedad: una vuelta de tool use. En la práctica se hace loop hasta que el modelo devuelve solo texto.
for (const block of response.content) {
if (block.type === 'tool_use') {
const tool = tools.find((t) => t.name === block.name)
if (!tool) continue
const result = await tool.run(block.input as Record<string, unknown>)
transcript.push(`tool=${tool.name} input=${JSON.stringify(block.input)} result=${result}`)
} else if (block.type === 'text') {
transcript.push(`text=${block.text}`)
}
}
await db.from('agent_transcripts').insert({
actor_user_id: ctx.userId,
actor_role: ctx.role,
task,
transcript: transcript.join('\n'),
})
return { transcript: transcript.join('\n'), result: transcript[transcript.length - 1] ?? '' }
}
5. El esquema admin
Cinco tablas. admin_roles lista al equipo. audit_log registra cada escritura. internal_notes guarda las notas que el equipo pega a las entidades. saved_searches deja al equipo guardar los conjuntos de filtros que reutiliza. agent_transcripts guarda cada run de agente. Policies RLS en cada tabla; solo los roles correctos ven las filas correctas.
-- supabase/schema/admin.sql
create table if not exists admin_roles (
user_id uuid primary key references auth.users(id) on delete cascade,
role text not null check (role in ('admin', 'ops', 'support', 'finance', 'read_only')),
created_at timestamptz default now()
);
create table if not exists audit_log (
id uuid primary key default gen_random_uuid(),
actor_user_id uuid references admin_roles(user_id),
actor_role text not null,
entity_type text not null,
entity_id text not null,
action text not null,
before jsonb,
after jsonb,
justification text,
created_at timestamptz default now()
);
create index audit_log_entity_idx on audit_log (entity_type, entity_id, created_at desc);
create table if not exists internal_notes (
id uuid primary key default gen_random_uuid(),
entity_type text not null,
entity_id text not null,
author_user_id uuid references admin_roles(user_id),
body text not null,
created_at timestamptz default now()
);
create table if not exists saved_searches (
id uuid primary key default gen_random_uuid(),
owner_user_id uuid references admin_roles(user_id),
name text not null,
entity_type text not null,
filters jsonb not null,
created_at timestamptz default now()
);
create table if not exists agent_transcripts (
id uuid primary key default gen_random_uuid(),
actor_user_id uuid references admin_roles(user_id),
actor_role text not null,
task text not null,
transcript text not null,
created_at timestamptz default now()
);
alter table admin_roles enable row level security;
alter table audit_log enable row level security;
alter table internal_notes enable row level security;
alter table saved_searches enable row level security;
alter table agent_transcripts enable row level security;
create policy "admin_roles read self" on admin_roles for select
using (auth.uid() = user_id or current_setting('request.jwt.claim.role') = 'admin');
create policy "audit_log read by role" on audit_log for select
using (current_setting('request.jwt.claim.role') in ('admin', 'finance'));
6. Lo que esto compone
El middleware SSO deja fuera a la gente equivocada. El helper de roles deja a la gente correcta dentro de su carril. El runner de bulk hace al equipo rápido y a los cambios trazables. El agente hace el trabajo que el equipo le pasaría a un becario, con un transcript que el equipo puede leer. El esquema hace que todo viva en un sitio que no se cuela en el producto.
El equipo de ops deja de ser una pipeline ETL humana. El Google Sheet que era espejo de la tabla de clientes está borrado. La reconciliación CSV del viernes corre desde la admin en 12 segundos. Los hilos de Slack que cerraban con por favor actualizad estos IDs pasan a ser formularios con diálogo de confirmación y fila de audit. El engineer que escribía un script cada semana vuelve a trabajar en el producto. El equipo que había crecido convirtiéndose en una empresa de mano de obra vuelve a ser una empresa de software.