Files
AnchorOS/docs/APERTURE_SPECS.md
Marco Gallegos 51dc8f607e docs: Add granular permissions system to Aperture specs
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
2026-01-17 10:58:02 -06:00

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

  1. Tiempo Programado:

    • Basado en la duración de los servicios agendados
    • Calculado desde bookings.start_time_utc hasta bookings.end_time_utc
    • Excluye tiempos de espera entre citas
  2. 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

  1. Tiempo programado: Base para el cálculo de nómina
  2. Tiempo real: Ajustes permitidos por staff (por ex: cliente llegó tarde, servicio se extendió)
  3. Tiempo extra: Se paga al 100% si fue trabajo adicional
  4. Tiempo faltante: Se descuenta del pago (horarios no cubiertos por citas)
  5. 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_at timestamp)

2. Registro de Ventas

  • Todas las transacciones se asocian al cashier_id activo
  • Sistema calcula totales por método de pago en tiempo real

3. Cierre de Caja

  • Cajero cierra el día:
    1. Conta efectivo en caja
    2. Ingresa closing_balance real
    3. Sistema calcula cash_difference
    4. Genera reporte PDF automático
    5. 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

  1. 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
  2. Validaciones:

    • Validar todos los inputs de usuario
    • Verificar permisos antes de permitir acciones
    • Validar montos de pagos (rangos aceptables)
  3. Auditoría:

    • Todas las acciones financieras deben registrarse en audit_logs
    • Incluir: acción, usuario, timestamp, detalles

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


11. Notas Importantes

  1. Precios Inteligentes:

    • Configurables por servicio
    • Aplican a ambos canales (booking + POS)
    • Solo activables en temporada alta (backend toggle)
  2. Sin Impresión de Recibos:

    • Email a cliente
    • Dashboard del cliente
    • Reporte PDF al dueño (cierre de caja)
  3. Múltiples Cajeros:

    • Cada cajero con su propio cierre de caja
    • Rastreo de errores por usuario específico
    • Control de movimientos para investigación
  4. 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>
  )
}