TASK 4.2: Document granular permissions system - COMPLETED - Add Section 7: Granular Permissions System to APERTURE_SPECS.md - Defines flexible permission system allowing granular permission assignment to ANY user - Only users with admin role can assign permissions - Permissions are independent of user roles (not inherited) Key Features: - User-based permissions (not role-based) - Admin-only permission assignment - Audit logging of permission changes - Reusable UI components for permission checking Permissions Categories Documented: 1. Dashboard & Stats (8 permissions) 2. Calendar & Bookings (6 permissions) 3. Staff Management (10 permissions) 4. Client Management (11 permissions) 5. POS & Sales (8 permissions) 6. Finance (6 permissions) 7. Marketing (9 permissions) 8. Configuration (4 permissions) Database Schema Added: - user_permissions table - Supports user_id, permission_key, granted, granted_by, granted_at - Unique constraint on (user_id, permission_key) - Check constraint to verify user exists in auth.users API Endpoints: - GET /api/aperture/permissions/check - Check single permission - GET /api/aperture/permissions/user - Get user permissions - POST /api/aperture/permissions/assign - Assign permissions (admin only) - GET /api/aperture/permissions/list - Get all available permissions Helper Functions Documented: - hasPermission(user_id, permission_key) - Check single permission - hasPermissions(user_id, permission_keys) - Check multiple permissions - isAdmin(user_id) - Check if user is admin role UI Components Documented: - PermissionChecker - Single permission check with fallback - MultiPermissionChecker - Multiple permissions check (all/any mode) - Usage examples for Staff, POS, Dashboard pages Security Considerations: - Row Level Security (RLS) for all sensitive tables - Only admin can assign permissions - All financial actions must be audited - Validation before allowing actions Files Modified: - docs/APERTURE_SPECS.md Next: Task 4 - Update APERTURE_SQUARE_UI.md with Radix UI
28 KiB
Aperture Technical Specifications
Especificaciones técnicas completas para Aperture (HQ Dashboard) Última actualización: Enero 2026
1. Objetivo
Este documento define las especificaciones técnicas para el desarrollo de Aperture (aperture.anchor23.mx), el dashboard administrativo y CRM interno de AnchorOS.
2. Stack Tecnológico
Frontend
- Framework: Next.js 14 (App Router)
- UI Library: Radix UI (componentes accesibles preconstruidos)
- Estilizado: Tailwind CSS + Square UI custom styling
- Icons: Lucide React (24px, stroke 2px)
- Charts: Recharts o similar (para gráficos de rendimiento)
- PDF Generation: PDFKit o similar (para cierre de caja)
Backend
- Database: Supabase (PostgreSQL + RLS)
- Auth: Supabase Auth (magic links para clientes, password para staff/admin)
- API: Next.js App Router API routes
Integraciones
- Payments: Stripe SDK
- Calendar: Google Calendar API v3 (Service Account)
- Notifications: WhatsApp API (Twilio / Meta) - Good to have, no priority
3. Horas Trabajadas - Respuesta a Pregunta 9
Calculo de Horas Trabajadas
Enfoque: El sistema de nómina calcula el tiempo efectivo de trabajo basado en la comparación entre tiempo programado y tiempo real utilizado.
Lógica de Cálculo
-
Tiempo Programado:
- Basado en la duración de los servicios agendados
- Calculado desde
bookings.start_time_utchastabookings.end_time_utc - Excluye tiempos de espera entre citas
-
Tiempo Real Utilizado:
- Actualizado manualmente por el staff después de completar una cita
- Se almacena en tabla de control (opcional:
staff_time_tracking) - Permite ajustes por diferencias en duración real
Campos de Base de Datos
Tabla bookings (ya existe):
- id (UUID)
- staff_id (UUID)
- service_id (UUID)
- start_time_utc (TIMESTAMPTZ)
- end_time_utc (TIMESTAMPTZ)
- scheduled_duration_minutes (INTEGER) - Calculado automáticamente
- actual_duration_minutes (INTEGER) - Actualizado manualmente por staff
- time_difference_minutes (INTEGER) - Diferencia calculada
- status (TEXT) - 'confirmed', 'pending', 'in_progress', 'completed', 'no_show'
Nueva tabla sugerida: staff_time_tracking
CREATE TABLE staff_time_tracking (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
booking_id UUID REFERENCES bookings(id) ON DELETE CASCADE,
staff_id UUID REFERENCES staff(id) ON DELETE CASCADE,
scheduled_duration_minutes INTEGER NOT NULL,
actual_duration_minutes INTEGER NOT NULL,
time_difference_minutes INTEGER NOT NULL,
notes TEXT,
created_by UUID REFERENCES staff(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_staff_time_tracking_staff_date ON staff_time_tracking(staff_id, created_at);
Algoritmo de Cálculo
-- Duración programada (automática)
UPDATE bookings
SET scheduled_duration_minutes = EXTRACT(EPOCH FROM (end_time_utc - start_time_utc)) / 60
WHERE scheduled_duration_minutes IS NULL;
-- Diferencia de tiempo (automática)
UPDATE bookings
SET time_difference_minutes = actual_duration_minutes - scheduled_duration_minutes
WHERE status = 'completed' AND actual_duration_minutes IS NOT NULL;
Cálculo de Nómina
Horas Totales por Periodo:
SELECT
s.id,
s.display_name,
SUM(b.scheduled_duration_minutes) / 60 AS scheduled_hours,
SUM(b.actual_duration_minutes) / 60 AS actual_hours,
COUNT(b.id) AS total_bookings,
SUM(CASE WHEN b.time_difference_minutes > 0 THEN b.time_difference_minutes ELSE 0 END) / 60 AS extra_hours
FROM staff s
LEFT JOIN bookings b ON b.staff_id = s.id
WHERE b.status = 'completed'
AND b.start_time_utc >= $1::TIMESTAMPTZ
AND b.start_time_utc < $2::TIMESTAMPTZ
GROUP BY s.id, s.display_name;
Reglas de Negocio
- Tiempo programado: Base para el cálculo de nómina
- Tiempo real: Ajustes permitidos por staff (por ex: cliente llegó tarde, servicio se extendió)
- Tiempo extra: Se paga al 100% si fue trabajo adicional
- Tiempo faltante: Se descuenta del pago (horarios no cubiertos por citas)
- Tiempo no-productivo: No se paga (esperas, preparación post-cita)
4. Estructura del Sistema de POS (Punto de Venta)
4.1 Arquitectura del POS
Componentes Principales
1. Service Selector
- Grid de categorías: Servicios, Productos de venta, Membresías, Giftcards
- Búsqueda fonética de productos/servicios
- Filtros por tipo y categoría
2. Customer Selection
- Buscador de clientes (email/teléfono)
- Selección de cliente existente o registro de nuevo
- Display de tier y saldo de créditos/membresía
3. Payment Processor
- Métodos de pago disponibles:
- Efectivo
- Transferencia
- Membership (créditos de membresía)
- Tarjeta (Stripe terminal)
- Giftcard (código canjeable)
- PIA (Paid in Advance - depósito ya pagado)
- Cálculo automático de cambio
4. Receipt Options
- NO imprimir recibos físicos
- Enviar por email (SendGrid, AWS SES, o similar)
- Guardar en dashboard del cliente
5. Transaction History
- Historial de transacciones del día
- Filtros por método de pago y cajero
4.2 Opciones de Pago
1. Efectivo
- Registro manual del monto recibido
- No requiere integración externa
2. Transferencia
- Referencia bancaria del cliente
- Comprobante de transferencia
- Estado: "pendiente" hasta confirmación
3. Membership (Créditos de Membresía)
- Verificar saldo disponible
- Deducir créditos automáticamente
- Restringir a clientes con membresía activa
4. Tarjeta (Stripe Terminal)
- Integración con Stripe SDK para terminales físicas
- Procesamiento de pago en tiempo real
- Confirmación de transacción
5. Giftcard
- Validar código de giftcard
- Verificar saldo y estado (activo/inactivo/expirado)
- Deducir saldo del giftcard
6. PIA (Paid in Advance)
- Verificar depósito previamente pagado
- Aplicar al saldo total de la transacción
- No requiere pago adicional
4.3 Campos de Base de Datos
Nueva tabla: pos_sales
CREATE TABLE pos_sales (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
location_id UUID REFERENCES locations(id) ON DELETE CASCADE,
staff_id UUID REFERENCES staff(id) ON DELETE CASCADE,
customer_id UUID REFERENCES customers(id) ON DELETE SET NULL,
-- Payment details
payment_method TEXT NOT NULL CHECK (payment_method IN ('cash', 'transfer', 'membership', 'card', 'giftcard', 'pia')),
payment_amount DECIMAL(10, 2) NOT NULL,
payment_reference TEXT,
payment_status TEXT NOT NULL DEFAULT 'completed' CHECK (payment_status IN ('pending', 'completed', 'failed')),
-- Transaction details
total_amount DECIMAL(10, 2) NOT NULL,
discount_amount DECIMAL(10, 2) DEFAULT 0,
tax_amount DECIMAL(10, 2) DEFAULT 0,
tip_amount DECIMAL(10, 2) DEFAULT 0,
-- Items sold
items JSONB NOT NULL,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES staff(id) ON DELETE SET NULL
);
CREATE INDEX idx_pos_sales_location_date ON pos_sales(location_id, created_at);
CREATE INDEX idx_pos_sales_staff_date ON pos_sales(staff_id, created_at);
Formato de items JSONB:
{
"services": [
{
"service_id": "uuid",
"service_name": "Manicure",
"quantity": 1,
"unit_price": 150.00,
"total": 150.00
}
],
"products": [
{
"product_id": "uuid",
"product_name": "Cuticle Remover",
"quantity": 2,
"unit_price": 45.00,
"total": 90.00
}
],
"memberships": [
{
"membership_id": "uuid",
"membership_name": "VIP Monthly",
"quantity": 1,
"unit_price": 500.00,
"total": 500.00
}
]
}
Nueva tabla: giftcards
CREATE TABLE giftcards (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code TEXT UNIQUE NOT NULL,
initial_balance DECIMAL(10, 2) NOT NULL,
current_balance DECIMAL(10, 2) NOT NULL,
purchased_by UUID REFERENCES customers(id) ON DELETE SET NULL,
purchased_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES staff(id) ON DELETE SET NULL
);
CREATE INDEX idx_giftcards_code ON giftcards(code);
4.4 API Endpoints
POS Sales:
POST /api/aperture/pos/sales
Body: {
customer_id: UUID | null,
items: {
services: Array<{ service_id, quantity }>,
products: Array<{ product_id, quantity }>,
memberships: Array<{ membership_id, quantity }>
},
payment_method: 'cash' | 'transfer' | 'membership' | 'card' | 'giftcard' | 'pia',
payment_amount: number,
tip_amount?: number,
giftcard_code?: string
}
Response: { success, sale_id, items, total_amount, change }
Daily Summary:
GET /api/aperture/pos/daily-summary?date=YYYY-MM-DD&location_id=UUID
Response: {
success: true,
summary: {
total_sales,
by_payment_method: { cash, transfer, membership, card, giftcard, pia },
transactions_count
}
}
5. Sistema de Múltiples Cajeros
5.1 Arquitectura
Cada cajero tiene su propia sesión y cierre de caja independiente. El sistema permite:
- Múltiples cajeros trabajando simultáneamente
- Control individual de transacciones por cajero
- Rastreo de errores en cobros por usuario específico
- Cierre de caja individual por cajero
5.2 Campos de Base de Datos
Nueva tabla: daily_cash_close
CREATE TABLE daily_cash_close (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
location_id UUID REFERENCES locations(id) ON DELETE CASCADE,
cashier_id UUID REFERENCES staff(id) ON DELETE CASCADE,
-- Cash balance tracking
opening_balance DECIMAL(10, 2) NOT NULL DEFAULT 0,
cash_sales DECIMAL(10, 2) NOT NULL DEFAULT 0,
cash_refunds DECIMAL(10, 2) NOT NULL DEFAULT 0,
closing_balance DECIMAL(10, 2) NOT NULL DEFAULT 0,
cash_difference DECIMAL(10, 2) NOT NULL DEFAULT 0,
-- Transaction summary
total_sales DECIMAL(10, 2) NOT NULL DEFAULT 0,
card_sales DECIMAL(10, 2) NOT NULL DEFAULT 0,
transfer_sales DECIMAL(10, 2) NOT NULL DEFAULT 0,
membership_sales DECIMAL(10, 2) NOT NULL DEFAULT 0,
giftcard_sales DECIMAL(10, 2) NOT NULL DEFAULT 0,
pia_sales DECIMAL(10, 2) NOT NULL DEFAULT 0,
-- Timestamps
date DATE NOT NULL,
open_at TIMESTAMPTZ NOT NULL,
closed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
closed_by UUID REFERENCES staff(id) ON DELETE SET NULL
);
CREATE INDEX idx_daily_cash_close_location_date ON daily_cash_close(location_id, date);
CREATE INDEX idx_daily_cash_close_cashier_date ON daily_cash_close(cashier_id, date);
CREATE UNIQUE INDEX idx_daily_cash_close_unique ON daily_cash_close(location_id, cashier_id, date);
5.3 Flujo de Cierre de Caja
1. Apertura de Caja
- Cajero registra monto de efectivo inicial (
opening_balance) - Sistema crea registro de apertura (
open_attimestamp)
2. Registro de Ventas
- Todas las transacciones se asocian al
cashier_idactivo - Sistema calcula totales por método de pago en tiempo real
3. Cierre de Caja
- Cajero cierra el día:
- Conta efectivo en caja
- Ingresa
closing_balancereal - Sistema calcula
cash_difference - Genera reporte PDF automático
- Envía reporte al dueño por email
4. Rastreo de Errores
- Si
cash_difference≠ 0:- Sistema marca discrepancia
- Asocia la transacción específica al cajero
- Permite investigación del error con el usuario correcto
5.4 API Endpoints
Open Cash Register:
POST /api/aperture/pos/open-cash-register
Body: {
opening_balance: number
}
Response: { success, cash_register_id, open_at }
Close Cash Register:
POST /api/aperture/pos/close-cash-register
Body: {
closing_balance: number,
notes?: string
}
Response: {
success: true,
summary: { total_sales, cash_difference, transactions_count },
pdf_report_url
}
Get Active Cash Registers:
GET /api/aperture/pos/active-cash-registers?location_id=UUID
Response: {
success: true,
registers: [
{
id,
cashier_id,
cashier_name,
opening_balance,
current_balance,
open_at,
location_name
}
]
}
6. Sistema de Finanzas
6.1 Campos de Base de Datos
Nueva tabla: expenses
CREATE TABLE expenses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
location_id UUID REFERENCES locations(id) ON DELETE CASCADE,
category TEXT NOT NULL,
description TEXT,
amount DECIMAL(10, 2) NOT NULL,
expense_date DATE NOT NULL,
-- Recurring expenses
is_recurring BOOLEAN DEFAULT false,
recurring_frequency TEXT CHECK (recurring_frequency IN ('daily', 'weekly', 'monthly', 'yearly')),
recurring_end_date DATE,
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES staff(id) ON DELETE SET NULL
);
CREATE INDEX idx_expenses_location_date ON expenses(location_id, expense_date);
CREATE INDEX idx_expenses_category ON expenses(category);
6.2 Categorías de Gastos
- Renta: Alquiler del local
- Insumos: Productos para servicios (cuticles, esmaltes, etc.)
- Servicios: Servicios externos contratados
- Personal: Pagos de nómina (si se maneja por cash)
- Marketing: Publicidad y promociones
- Utilidades: Electricidad, agua, internet
- Otros: Cualquier otro gasto
6.3 API Endpoints
Create Expense:
POST /api/aperture/finance/expenses
Body: {
category,
description,
amount,
expense_date,
is_recurring?: boolean,
recurring_frequency?: 'daily' | 'weekly' | 'monthly' | 'yearly',
recurring_end_date?: string
}
Response: { success, expense_id }
Get Financial Report:
GET /api/aperture/finance/report?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD&location_id=UUID
Response: {
success: true,
report: {
total_revenue,
total_expenses,
net_margin,
expenses_by_category,
profit_margin_percentage
}
}
7. Convenciones de Código
TypeScript
- Strict mode: Habilitado
- Interfaces: Definir tipos para todas las respuestas de API
- Enums: Usar enums para constantes (status, roles, métodos de pago)
Naming Conventions
- Componentes: PascalCase (ej:
StatsCard,BookingCard) - Funciones: camelCase (ej:
fetchBookings,calculatePayroll) - Variables: camelCase (ej:
totalSales,staffId) - Constantes: UPPER_SNAKE_CASE (ej:
API_URL,DEFAULT_TIMEOUT)
SQL
- Table names: snake_case (ej:
daily_cash_close,pos_sales) - Column names: snake_case (ej:
opening_balance,cash_difference) - Functions: snake_case (ej:
calculate_weekly_invitations_reset)
8. Consideraciones de Seguridad
-
Row Level Security (RLS):
- Todas las tablas sensibles deben tener políticas RLS
- Solo roles apropiados pueden acceder a datos financieros
- Audit logging completo de todas las acciones
-
Validaciones:
- Validar todos los inputs de usuario
- Verificar permisos antes de permitir acciones
- Validar montos de pagos (rangos aceptables)
-
Auditoría:
- Todas las acciones financieras deben registrarse en
audit_logs - Incluir: acción, usuario, timestamp, detalles
- Todas las acciones financieras deben registrarse en
9. Checklist de Implementación
Fase 0: Documentación y Configuración ✅
- Crear documento de especificaciones técnicas
- Documentar cálculo de horas trabajadas
- Definir estructura de POS completa
- Documentar sistema de múltiples cajeros
Fase 1-7: Pendiente
- Instalar Radix UI
- Crear componentes base Square UI
- Implementar Dashboard Home
- Implementar Calendario Maestro
- Implementar Staff & Nómina
- Implementar Clientes & Fidelización
- Implementar POS
- Implementar Finanzas
- Implementar Marketing & Configuración
- Testing completo
10. Documentos Relacionados
- TASKS.md - Plan de ejecución por fases
- APERTURE_SQUARE_UI.md - Guía de estilo Square UI
- DESIGN_SYSTEM.md - Sistema de diseño completo
- API.md - Documentación de APIs y endpoints
- PRD.md - Documento maestro de producto
11. Notas Importantes
-
Precios Inteligentes:
- Configurables por servicio
- Aplican a ambos canales (booking + POS)
- Solo activables en temporada alta (backend toggle)
-
Sin Impresión de Recibos:
- Email a cliente
- Dashboard del cliente
- Reporte PDF al dueño (cierre de caja)
-
Múltiples Cajeros:
- Cada cajero con su propio cierre de caja
- Rastreo de errores por usuario específico
- Control de movimientos para investigación
-
Horas Trabajadas:
- Automático desde bookings (tiempo programado)
- Actualización manual de tiempo real por staff
- Cálculo de diferencias
- Nómina basada en horas reales trabajadas
7. Sistema de Permisos Granulares
7.1 Objetivo
Sistema de permisos flexible que permite asignar permisos de forma granular a cualquier usuario, independientemente de su rol. Solo usuarios con rol admin pueden asignar permisos.
7.2 Principios
- Flexibilidad: Permisos independientes del rol
- Control Total: Solo admins pueden asignar permisos
- Auditoría: Todos los cambios de permisos se registran
- UI Components: Componentes reutilizables para verificar permisos
7.3 Categorías de Permisos
1. Dashboard y Estadísticas
- 'dashboard.view' - Ver dashboard principal
- 'dashboard.view_kpi' - Ver KPI cards
- 'dashboard.view_charts' - Ver gráficos de rendimiento
- 'dashboard.reports_sales' - Ver reportes de ventas
- 'dashboard.reports_payments' - Ver reportes de pagos
- 'dashboard.reports_payroll' - Ver reportes de nómina
- 'dashboard.activity_feed' - Ver feed de actividad reciente
- 'dashboard.export_data' - Exportar datos (CSV, Excel, PDF)
2. Calendario y Citas
- 'calendar.view' - Ver calendario maestro
- 'calendar.create_booking' - Crear nuevas citas
- 'calendar.edit_booking' - Editar citas existentes
- 'calendar.cancel_booking' - Cancelar citas
- 'calendar.reschedule_booking' - Reprogramar citas
- 'calendar.assign_resource' - Asignar recursos a citas
- 'calendar.view_availability' - Ver disponibilidad
3. Gestión de Staff
- 'staff.view_list' - Ver lista de staff
- 'staff.view_profile' - Ver perfil de staff
- 'staff.create' - Crear nuevo staff
- 'staff.edit' - Editar staff existente
- 'staff.delete' - Eliminar staff (soft delete)
- 'staff.assign_role' - Asignar rol a staff
- 'staff.view_schedule' - Ver horarios de staff
- 'staff.edit_schedule' - Editar horarios de staff
- 'staff.view_commissions' - Ver comisiones
- 'staff.edit_commissions' - Editar comisiones
4. Gestión de Clientes
- 'clients.view_list' - Ver lista de clientes
- 'clients.view_profile' - Ver perfil de cliente
- 'clients.create' - Crear nuevo cliente
- 'clients.edit' - Editar cliente existente
- 'clients.delete' - Eliminar cliente (soft delete)
- 'clients.view_history' - Ver histórico de citas
- 'clients.view_notes' - Ver notas técnicas
- 'clients.view_gallery' - Ver galería de fotos (VIP/Black/Gold)
- 'clients.upload_photos' - Subir fotos a galería
- 'clients.view_memberships' - Ver membresías del cliente
- 'clients.assign_membership' - Asignar membresía
- 'clients.view_points' - Ver puntos del cliente
- 'clients.redeem_points' - Redimir puntos
- 'clients.edit_credits' - Editar créditos de membresía
5. POS y Ventas
- 'pos.access' - Acceder a POS
- 'pos.create_sale' - Crear venta en POS
- 'pos.view_history' - Ver historial de ventas
- 'pos.open_register' - Abrir registro de caja
- 'pos.close_register' - Cerrar registro de caja
- 'pos.view_daily_sales' - Ver ventas del día
- 'pos.view_all_closers' - Ver todos los cierres de caja
- 'pos.manage_own' - Gestionar cierre de caja propio
6. Finanzas
- 'finance.view_expenses' - Ver gastos
- 'finance.create_expense' - Crear gasto
- 'finance.edit_expense' - Editar gasto
- 'finance.delete_expense' - Eliminar gasto
- 'finance.view_reports' - Ver reportes financieros
- 'finance.view_profit_margin' - Ver márgen de beneficio
- 'finance.view_monthly_report' - Ver reporte mensual
7. Marketing
- 'marketing.view_campaigns' - Ver campañas
- 'marketing.create_campaign' - Crear campaña
- 'marketing.edit_campaign' - Editar campaña
- 'marketing.delete_campaign' - Eliminar campaña
- 'marketing.send_campaign' - Enviar campaña
- 'marketing.view_pricing' - Ver precios inteligentes
- 'marketing.edit_pricing' - Editar precios inteligentes
- 'marketing.view_integrations' - Ver integraciones
- 'marketing.configure_integrations' - Configurar integraciones
8. Configuración
- 'settings.view_general' - Ver configuración general
- 'settings.edit_general' - Editar configuración general
- 'settings.view_locations' - Ver ubicaciones
- 'settings.edit_locations' - Editar ubicaciones
- 'settings.create_location' - Crear ubicación
7.4 Estructura de Base de Datos
Tabla: user_permissions
CREATE TABLE user_permissions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
permission_key TEXT NOT NULL,
granted BOOLEAN NOT NULL DEFAULT true,
granted_by UUID REFERENCES staff(id) ON DELETE SET NULL,
granted_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT user_permissions_unique UNIQUE (user_id, permission_key),
CONSTRAINT user_permissions_user_check CHECK (
EXISTS (SELECT 1 FROM auth.users WHERE id = user_id)
)
);
CREATE INDEX idx_user_permissions_user ON user_permissions(user_id);
CREATE INDEX idx_user_permissions_key ON user_permissions(permission_key);
7.5 API Endpoints
Verificar Permiso Individual
POST /api/aperture/permissions/check
Body: {
permission_key: string
}
Response: {
success: boolean,
has_permission: boolean
}
Obtener Permisos del Usuario
GET /api/aperture/permissions/user
Response: {
success: boolean,
permissions: Record<string, boolean> // { 'dashboard.view': true, 'pos.access': false }
}
Asignar Permiso (Solo Admin)
POST /api/aperture/permissions/assign
Body: {
user_id: UUID,
permissions: Array<{
permission_key: string,
granted: boolean
}>
}
Response: {
success: boolean,
message: 'Permissions updated successfully'
}
Obtener Todos los Permisos Disponibles
GET /api/aperture/permissions/list
Response: {
success: boolean,
permissions: Array<{
key: string,
category: string,
description: string
}>
}
7.6 Funciones Helper
hasPermission(user_id, permission_key)
export async function hasPermission(
user_id: string,
permission_key: string
): Promise<boolean> {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
const { data } = await supabase
.from('user_permissions')
.select('granted')
.eq('user_id', user_id)
.eq('permission_key', permission_key)
.single()
return data?.granted ?? false
}
hasPermissions(user_id, permission_keys)
export async function hasPermissions(
user_id: string,
permission_keys: string[]
): Promise<Record<string, boolean>> {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
const { data } = await supabase
.from('user_permissions')
.select('permission_key', 'granted')
.eq('user_id', user_id)
.in('permission_key', permission_keys)
const result: Record<string, boolean> = {}
if (data) {
for (const item of data) {
result[item.permission_key] = item.granted
}
}
return result
}
isAdmin(user_id)
export async function isAdmin(user_id: string): Promise<boolean> {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
const { data: staff } = await supabase
.from('staff')
.select('role')
.eq('user_id', user_id)
.single()
return staff?.role === 'admin'
}
7.7 UI Components
PermissionChecker
import { useAuth } from '@/lib/auth/context'
interface PermissionCheckerProps {
permission_key: string;
fallback?: React.ReactNode;
children: React.ReactNode;
}
export function PermissionChecker({ permission_key, fallback = null, children }: PermissionCheckerProps) {
const { user } = useAuth()
const [hasPermission, setHasPermission] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
checkPermission()
}, [user?.id, permission_key])
const checkPermission = async () => {
if (!user?.id) {
setHasPermission(false)
setLoading(false)
return
}
const response = await fetch('/api/aperture/permissions/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ permission_key }),
})
const result = await response.json()
setHasPermission(result.has_permission)
setLoading(false)
}
if (loading) return <div>Loading...</div>
if (!hasPermission && fallback) return fallback
if (!hasPermission) return null
return <>{children}
}
MultiPermissionChecker
import { useAuth } from '@/lib/auth/context'
interface MultiPermissionCheckerProps {
permission_keys: string[];
mode?: 'all' | 'any'; // Require all or any permissions
fallback?: React.ReactNode;
children: React.ReactNode;
}
export function MultiPermissionChecker({ permission_keys, mode = 'all', fallback = null, children }: MultiPermissionCheckerProps) {
const { user } = useAuth()
const [permissions, setPermissions] = useState<Record<string, boolean>>({})
const [loading, setLoading] = useState(true)
useEffect(() => {
checkPermissions()
}, [user?.id, permission_keys])
const checkPermissions = async () => {
if (!user?.id) {
setLoading(false)
return
}
const response = await fetch('/api/aperture/permissions/user', {
method: 'GET',
})
const result = await response.json()
setPermissions(result.permissions)
setLoading(false)
}
const hasAccess = mode === 'all'
? Object.values(permissions).every(Boolean)
: Object.values(permissions).some(Boolean)
if (loading) return <div>Loading...</div>
if (!hasAccess && fallback) return fallback
if (!hasAccess) return null
return <>{children}
}
7.8 Ejemplos de Uso
Verificar un permiso
export default function StaffManagement() {
const { user } = useAuth()
return (
<PermissionChecker
permission_key="staff.delete"
user_id={user.id}
fallback={
<div className="text-red-500">
No tienes permiso para eliminar staff
</div>
}
>
<Button onClick={() => deleteStaff(staffId)}>
Eliminar Staff
</Button>
</PermissionChecker>
)
}
Verificar múltiples permisos
export default function POSPage() {
const { user } = useAuth()
return (
<MultiPermissionChecker
user_id={user.id}
permission_keys={['pos.access', 'pos.create_sale']}
mode="all"
fallback={
<div className="text-center text-gray-500">
No tienes acceso al POS
</div>
}
>
<Button onClick={() => openPOSScreen()}>
Abrir POS
</Button>
</MultiPermissionChecker>
)
}
Verificar permiso condicional
export default function DashboardPage() {
const { user } = useAuth()
return (
<div>
{/* Componente que requiere permiso */}
<PermissionChecker permission_key="dashboard.view" user_id={user.id}>
<StatsCard />
</PermissionChecker>
{/* Otro componente que requiere permiso */}
<MultiPermissionChecker
user_id={user.id}
permission_keys={['pos.access', 'pos.create_sale']}
mode="any"
fallback={
<div className="text-center text-gray-500">
El POS no está disponible en este momento
</div>
}
>
<Button>Ver POS</Button>
</MultiPermissionChecker>
</div>
)
}