Initial commit - Database de template-saas migrado desde monorepo
Migración desde workspace-v2/projects/template-saas/apps/database Este repositorio es parte del estándar multi-repo v2 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
293e228945
commit
3ce06fbce4
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
185
README.md
185
README.md
@ -1,3 +1,184 @@
|
|||||||
# template-saas-database-v2
|
# Template SaaS - Database
|
||||||
|
|
||||||
Database de template-saas - Workspace V2
|
**Version:** 1.0.0
|
||||||
|
**Database:** PostgreSQL 16+
|
||||||
|
**Multi-tenancy:** Row-Level Security (RLS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schemas
|
||||||
|
|
||||||
|
| Schema | Descripcion | RLS |
|
||||||
|
|--------|-------------|-----|
|
||||||
|
| auth | Sesiones, tokens, OAuth | Si |
|
||||||
|
| tenants | Tenants y configuracion | No* |
|
||||||
|
| users | Usuarios, roles, permisos | Si |
|
||||||
|
| billing | Subscripciones, facturas, pagos | Si |
|
||||||
|
| plans | Planes y features | No |
|
||||||
|
| audit | Logs de auditoria y actividad | Si |
|
||||||
|
| notifications | Templates y notificaciones | Si |
|
||||||
|
| feature_flags | Feature flags y evaluaciones | Si |
|
||||||
|
| storage | Archivos y metadata | Si |
|
||||||
|
|
||||||
|
*tenants.tenants no usa RLS ya que es la tabla base de multi-tenancy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estructura de Archivos
|
||||||
|
|
||||||
|
```
|
||||||
|
database/
|
||||||
|
├── ddl/
|
||||||
|
│ ├── 00-extensions.sql # Extensiones PostgreSQL
|
||||||
|
│ ├── 01-schemas.sql # Creacion de schemas
|
||||||
|
│ ├── 02-enums.sql # Tipos enum
|
||||||
|
│ ├── 03-functions.sql # Funciones de utilidad
|
||||||
|
│ └── schemas/
|
||||||
|
│ ├── auth/tables/ # Sesiones, tokens, OAuth
|
||||||
|
│ ├── tenants/tables/ # Tenants, settings
|
||||||
|
│ ├── users/tables/ # Users, roles, invitations
|
||||||
|
│ ├── billing/tables/ # Subscriptions, invoices
|
||||||
|
│ ├── plans/tables/ # Plans, features
|
||||||
|
│ ├── audit/tables/ # Audit logs, activity
|
||||||
|
│ ├── notifications/tables/
|
||||||
|
│ └── feature_flags/tables/
|
||||||
|
├── seeds/
|
||||||
|
│ ├── prod/ # Seeds para produccion
|
||||||
|
│ └── dev/ # Seeds para desarrollo
|
||||||
|
└── scripts/
|
||||||
|
├── create-database.sh
|
||||||
|
└── drop-and-recreate.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Uso de RLS
|
||||||
|
|
||||||
|
### Establecer Contexto de Tenant
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- En cada request
|
||||||
|
SELECT auth.set_current_tenant('tenant-uuid-here');
|
||||||
|
|
||||||
|
-- Opcional: establecer usuario
|
||||||
|
SELECT auth.set_current_user('user-uuid-here');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Desde el Backend
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Middleware de tenant
|
||||||
|
async function setTenantContext(tenantId: string) {
|
||||||
|
await dataSource.query('SELECT auth.set_current_tenant($1)', [tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// En el request handler
|
||||||
|
const tenantId = req.user.tenantId;
|
||||||
|
await setTenantContext(tenantId);
|
||||||
|
// Ahora todas las queries respetan RLS
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Funciones de Utilidad
|
||||||
|
|
||||||
|
### Contexto
|
||||||
|
- `auth.set_current_tenant(uuid)` - Establece tenant para RLS
|
||||||
|
- `auth.get_current_tenant()` - Obtiene tenant actual
|
||||||
|
- `auth.set_current_user(uuid)` - Establece usuario actual
|
||||||
|
- `auth.clear_context()` - Limpia contexto
|
||||||
|
|
||||||
|
### Limites de Plan
|
||||||
|
- `plans.get_tenant_limits(tenant_id)` - Obtiene limites del plan
|
||||||
|
- `plans.check_limit(tenant_id, key, count)` - Verifica limite
|
||||||
|
- `plans.has_feature(tenant_id, feature_code)` - Verifica feature
|
||||||
|
|
||||||
|
### Usuarios
|
||||||
|
- `users.count_active_users(tenant_id)` - Cuenta usuarios activos
|
||||||
|
- `users.can_add_user(tenant_id)` - Puede agregar usuario
|
||||||
|
|
||||||
|
### Feature Flags
|
||||||
|
- `feature_flags.evaluate_flag(code, tenant_id, user_id)` - Evalua flag
|
||||||
|
|
||||||
|
### Utilidades
|
||||||
|
- `public.slugify(text)` - Genera slug
|
||||||
|
- `public.generate_token(length)` - Genera token aleatorio
|
||||||
|
- `public.hash_token(token)` - Hash SHA256
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
### Crear Base de Datos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/database/scripts
|
||||||
|
./create-database.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recrear Base de Datos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./drop-and-recreate.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variables de Entorno
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=template_saas_dev
|
||||||
|
DB_USER=template_saas_user
|
||||||
|
DB_PASSWORD=your_password
|
||||||
|
DB_ADMIN_USER=postgres
|
||||||
|
DB_ADMIN_PASSWORD=admin_password
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Orden de Ejecucion DDL
|
||||||
|
|
||||||
|
1. `00-extensions.sql`
|
||||||
|
2. `01-schemas.sql`
|
||||||
|
3. `02-enums.sql`
|
||||||
|
4. `schemas/tenants/tables/*.sql`
|
||||||
|
5. `schemas/plans/tables/*.sql`
|
||||||
|
6. `schemas/users/tables/*.sql`
|
||||||
|
7. `schemas/auth/tables/*.sql`
|
||||||
|
8. `schemas/billing/tables/*.sql`
|
||||||
|
9. `schemas/audit/tables/*.sql`
|
||||||
|
10. `schemas/notifications/tables/*.sql`
|
||||||
|
11. `schemas/feature_flags/tables/*.sql`
|
||||||
|
12. `03-functions.sql`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seeds Iniciales
|
||||||
|
|
||||||
|
### Produccion
|
||||||
|
- Planes default (Free, Starter, Pro, Enterprise)
|
||||||
|
- Roles de sistema (owner, admin, member)
|
||||||
|
- Permisos base
|
||||||
|
- Templates de notificacion
|
||||||
|
|
||||||
|
### Desarrollo
|
||||||
|
- Tenant de prueba
|
||||||
|
- Usuarios de prueba
|
||||||
|
- Datos de ejemplo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cambios Recientes
|
||||||
|
|
||||||
|
### 2026-01-07: Tabla payment_methods
|
||||||
|
- Agregada tabla `billing.payment_methods` para almacenar métodos de pago
|
||||||
|
- Agregado enum `billing.payment_method_type` ('card', 'bank_transfer', 'oxxo')
|
||||||
|
- Agregado enum `billing.subscription_status` ('trial', 'active', 'past_due', 'cancelled', 'expired')
|
||||||
|
- RLS habilitado para aislamiento multi-tenant
|
||||||
|
- Total tablas: 35
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Actualizado:** 2026-01-07
|
||||||
|
|||||||
22
ddl/00-extensions.sql
Normal file
22
ddl/00-extensions.sql
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Extensions
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- UUID generation
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
-- Full text search
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||||
|
|
||||||
|
-- JSON operations
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "btree_gin";
|
||||||
|
|
||||||
|
-- Timestamps and dates
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "btree_gist";
|
||||||
|
|
||||||
|
-- Comment
|
||||||
|
COMMENT ON EXTENSION "uuid-ossp" IS 'UUID generation functions';
|
||||||
|
COMMENT ON EXTENSION "pgcrypto" IS 'Cryptographic functions';
|
||||||
|
COMMENT ON EXTENSION "pg_trgm" IS 'Trigram matching for text search';
|
||||||
36
ddl/01-schemas.sql
Normal file
36
ddl/01-schemas.sql
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Schema Creation
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Core schemas
|
||||||
|
CREATE SCHEMA IF NOT EXISTS auth;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS tenants;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS users;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS billing;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS plans;
|
||||||
|
|
||||||
|
-- Feature schemas
|
||||||
|
CREATE SCHEMA IF NOT EXISTS audit;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS notifications;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS feature_flags;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS storage;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS ai;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS webhooks;
|
||||||
|
|
||||||
|
-- Integration schemas
|
||||||
|
CREATE SCHEMA IF NOT EXISTS whatsapp;
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON SCHEMA auth IS 'Authentication: sessions, tokens, OAuth';
|
||||||
|
COMMENT ON SCHEMA tenants IS 'Multi-tenancy: tenant configuration and settings';
|
||||||
|
COMMENT ON SCHEMA users IS 'User management: profiles, roles, permissions';
|
||||||
|
COMMENT ON SCHEMA billing IS 'Billing: subscriptions, invoices, payments';
|
||||||
|
COMMENT ON SCHEMA plans IS 'Plans: pricing plans, features, limits';
|
||||||
|
COMMENT ON SCHEMA audit IS 'Audit: activity logs, audit trail';
|
||||||
|
COMMENT ON SCHEMA notifications IS 'Notifications: templates, queue, delivery logs';
|
||||||
|
COMMENT ON SCHEMA feature_flags IS 'Feature flags: flags, rollouts, experiments';
|
||||||
|
COMMENT ON SCHEMA storage IS 'Storage: files, metadata, CDN';
|
||||||
|
COMMENT ON SCHEMA ai IS 'AI Integration: configurations, usage tracking, rate limits';
|
||||||
|
COMMENT ON SCHEMA webhooks IS 'Webhooks: outbound webhooks, deliveries, event dispatch';
|
||||||
|
COMMENT ON SCHEMA whatsapp IS 'WhatsApp: Business API integration, messages, configuration';
|
||||||
64
ddl/02-enums.sql
Normal file
64
ddl/02-enums.sql
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Enum Types
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Auth enums
|
||||||
|
CREATE TYPE auth.token_type AS ENUM ('access', 'refresh', 'reset_password', 'email_verification', 'api_key');
|
||||||
|
CREATE TYPE auth.oauth_provider AS ENUM ('google', 'microsoft', 'github', 'apple');
|
||||||
|
CREATE TYPE auth.session_status AS ENUM ('active', 'expired', 'revoked');
|
||||||
|
|
||||||
|
-- Tenant enums
|
||||||
|
CREATE TYPE tenants.tenant_status AS ENUM ('pending', 'active', 'suspended', 'cancelled');
|
||||||
|
CREATE TYPE tenants.subscription_status AS ENUM ('trialing', 'active', 'past_due', 'cancelled', 'unpaid');
|
||||||
|
|
||||||
|
-- User enums
|
||||||
|
CREATE TYPE users.user_status AS ENUM ('pending', 'active', 'inactive', 'suspended', 'pending_verification', 'deleted');
|
||||||
|
CREATE TYPE users.invitation_status AS ENUM ('pending', 'accepted', 'expired', 'cancelled');
|
||||||
|
|
||||||
|
-- Billing enums
|
||||||
|
CREATE TYPE billing.payment_status AS ENUM ('pending', 'processing', 'succeeded', 'failed', 'refunded', 'cancelled');
|
||||||
|
CREATE TYPE billing.invoice_status AS ENUM ('draft', 'open', 'paid', 'void', 'uncollectible');
|
||||||
|
CREATE TYPE billing.billing_interval AS ENUM ('month', 'year');
|
||||||
|
CREATE TYPE billing.payment_method_type AS ENUM ('card', 'bank_transfer', 'oxxo');
|
||||||
|
CREATE TYPE billing.subscription_status AS ENUM ('trial', 'active', 'past_due', 'cancelled', 'expired');
|
||||||
|
|
||||||
|
-- Notification enums
|
||||||
|
CREATE TYPE notifications.channel AS ENUM ('email', 'push', 'in_app', 'sms', 'whatsapp');
|
||||||
|
CREATE TYPE notifications.notification_status AS ENUM ('pending', 'sent', 'delivered', 'failed', 'read');
|
||||||
|
CREATE TYPE notifications.priority AS ENUM ('low', 'normal', 'high', 'urgent');
|
||||||
|
CREATE TYPE notifications.queue_status AS ENUM ('queued', 'processing', 'sent', 'failed', 'retrying');
|
||||||
|
CREATE TYPE notifications.device_type AS ENUM ('web', 'mobile', 'desktop');
|
||||||
|
|
||||||
|
-- Feature flag enums
|
||||||
|
CREATE TYPE feature_flags.flag_status AS ENUM ('disabled', 'enabled', 'percentage', 'user_list');
|
||||||
|
CREATE TYPE feature_flags.rollout_stage AS ENUM ('development', 'internal', 'beta', 'general');
|
||||||
|
|
||||||
|
-- Audit enums
|
||||||
|
CREATE TYPE audit.action_type AS ENUM ('create', 'read', 'update', 'delete', 'login', 'logout', 'export', 'import');
|
||||||
|
CREATE TYPE audit.severity AS ENUM ('info', 'warning', 'error', 'critical');
|
||||||
|
|
||||||
|
-- Storage enums
|
||||||
|
CREATE TYPE storage.file_status AS ENUM ('uploading', 'processing', 'ready', 'failed', 'deleted');
|
||||||
|
CREATE TYPE storage.visibility AS ENUM ('private', 'tenant', 'public');
|
||||||
|
CREATE TYPE storage.storage_provider AS ENUM ('s3', 'r2', 'minio', 'gcs');
|
||||||
|
|
||||||
|
-- AI enums
|
||||||
|
CREATE TYPE ai.ai_provider AS ENUM ('openrouter', 'openai', 'anthropic', 'google');
|
||||||
|
CREATE TYPE ai.ai_model_type AS ENUM ('chat', 'completion', 'embedding', 'image');
|
||||||
|
CREATE TYPE ai.usage_status AS ENUM ('pending', 'completed', 'failed', 'cancelled');
|
||||||
|
|
||||||
|
-- Webhook enums
|
||||||
|
CREATE TYPE webhooks.delivery_status AS ENUM ('pending', 'delivered', 'failed', 'retrying');
|
||||||
|
CREATE TYPE webhooks.event_type AS ENUM (
|
||||||
|
'user.created', 'user.updated', 'user.deleted',
|
||||||
|
'subscription.created', 'subscription.updated', 'subscription.cancelled',
|
||||||
|
'invoice.paid', 'invoice.failed',
|
||||||
|
'file.uploaded', 'file.deleted',
|
||||||
|
'tenant.updated'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- WhatsApp enums
|
||||||
|
CREATE TYPE whatsapp.message_status AS ENUM ('pending', 'sent', 'delivered', 'read', 'failed');
|
||||||
|
CREATE TYPE whatsapp.message_type AS ENUM ('text', 'template', 'image', 'document', 'audio', 'video', 'location', 'contacts', 'interactive');
|
||||||
|
CREATE TYPE whatsapp.message_direction AS ENUM ('outbound', 'inbound');
|
||||||
225
ddl/03-functions.sql
Normal file
225
ddl/03-functions.sql
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Core Functions
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- TENANT CONTEXT FUNCTIONS
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Set current tenant for RLS
|
||||||
|
CREATE OR REPLACE FUNCTION auth.set_current_tenant(p_tenant_id UUID)
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('app.current_tenant_id', p_tenant_id::TEXT, FALSE);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Get current tenant
|
||||||
|
CREATE OR REPLACE FUNCTION auth.get_current_tenant()
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN current_setting('app.current_tenant_id', TRUE)::UUID;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Set current user
|
||||||
|
CREATE OR REPLACE FUNCTION auth.set_current_user(p_user_id UUID)
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('app.current_user_id', p_user_id::TEXT, FALSE);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Get current user
|
||||||
|
CREATE OR REPLACE FUNCTION auth.get_current_user()
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN current_setting('app.current_user_id', TRUE)::UUID;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Clear context
|
||||||
|
CREATE OR REPLACE FUNCTION auth.clear_context()
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('app.current_tenant_id', '', FALSE);
|
||||||
|
PERFORM set_config('app.current_user_id', '', FALSE);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- PLAN & LIMITS FUNCTIONS
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Get tenant's current plan limits
|
||||||
|
CREATE OR REPLACE FUNCTION plans.get_tenant_limits(p_tenant_id UUID)
|
||||||
|
RETURNS JSONB AS $$
|
||||||
|
DECLARE
|
||||||
|
v_limits JSONB;
|
||||||
|
BEGIN
|
||||||
|
SELECT p.limits INTO v_limits
|
||||||
|
FROM tenants.tenants t
|
||||||
|
JOIN plans.plans p ON t.plan_id = p.id
|
||||||
|
WHERE t.id = p_tenant_id;
|
||||||
|
|
||||||
|
RETURN COALESCE(v_limits, '{}'::jsonb);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Check if tenant can perform action based on limits
|
||||||
|
CREATE OR REPLACE FUNCTION plans.check_limit(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_limit_key VARCHAR,
|
||||||
|
p_current_count INT
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
DECLARE
|
||||||
|
v_limit INT;
|
||||||
|
BEGIN
|
||||||
|
SELECT (plans.get_tenant_limits(p_tenant_id)->>p_limit_key)::INT INTO v_limit;
|
||||||
|
|
||||||
|
IF v_limit IS NULL THEN
|
||||||
|
RETURN TRUE; -- No limit defined
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN p_current_count < v_limit;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Get tenant's feature flags
|
||||||
|
CREATE OR REPLACE FUNCTION plans.get_tenant_features(p_tenant_id UUID)
|
||||||
|
RETURNS JSONB AS $$
|
||||||
|
DECLARE
|
||||||
|
v_features JSONB;
|
||||||
|
BEGIN
|
||||||
|
SELECT p.included_features INTO v_features
|
||||||
|
FROM tenants.tenants t
|
||||||
|
JOIN plans.plans p ON t.plan_id = p.id
|
||||||
|
WHERE t.id = p_tenant_id;
|
||||||
|
|
||||||
|
RETURN COALESCE(v_features, '[]'::jsonb);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Check if tenant has feature
|
||||||
|
CREATE OR REPLACE FUNCTION plans.has_feature(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_feature_code VARCHAR
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN plans.get_tenant_features(p_tenant_id) ? p_feature_code;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- USER COUNT FUNCTIONS
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Count active users in tenant
|
||||||
|
CREATE OR REPLACE FUNCTION users.count_active_users(p_tenant_id UUID)
|
||||||
|
RETURNS INT AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM users.users
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND status = 'active'
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Check if tenant can add more users
|
||||||
|
CREATE OR REPLACE FUNCTION users.can_add_user(p_tenant_id UUID)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN plans.check_limit(
|
||||||
|
p_tenant_id,
|
||||||
|
'max_users',
|
||||||
|
users.count_active_users(p_tenant_id)
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- UTILITY FUNCTIONS
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Update updated_at column automatically
|
||||||
|
CREATE OR REPLACE FUNCTION public.update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Create AI configs updated_at trigger (table defined in schemas/ai/)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'ai' AND table_name = 'configs') THEN
|
||||||
|
DROP TRIGGER IF EXISTS update_ai_configs_updated_at ON ai.configs;
|
||||||
|
CREATE TRIGGER update_ai_configs_updated_at
|
||||||
|
BEFORE UPDATE ON ai.configs
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Create webhooks updated_at trigger (table defined in schemas/webhooks/)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'webhooks' AND table_name = 'webhooks') THEN
|
||||||
|
DROP TRIGGER IF EXISTS webhooks_updated_at ON webhooks.webhooks;
|
||||||
|
CREATE TRIGGER webhooks_updated_at
|
||||||
|
BEFORE UPDATE ON webhooks.webhooks
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Generate slug from name
|
||||||
|
CREATE OR REPLACE FUNCTION public.slugify(p_text VARCHAR)
|
||||||
|
RETURNS VARCHAR AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN LOWER(
|
||||||
|
REGEXP_REPLACE(
|
||||||
|
REGEXP_REPLACE(
|
||||||
|
TRIM(p_text),
|
||||||
|
'[^a-zA-Z0-9\s-]', '', 'g'
|
||||||
|
),
|
||||||
|
'\s+', '-', 'g'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||||
|
|
||||||
|
-- Generate random token
|
||||||
|
CREATE OR REPLACE FUNCTION public.generate_token(p_length INT DEFAULT 32)
|
||||||
|
RETURNS VARCHAR AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN encode(gen_random_bytes(p_length), 'hex');
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Hash token
|
||||||
|
CREATE OR REPLACE FUNCTION public.hash_token(p_token VARCHAR)
|
||||||
|
RETURNS VARCHAR AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN encode(digest(p_token, 'sha256'), 'hex');
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON FUNCTION auth.set_current_tenant IS 'Set tenant context for RLS policies';
|
||||||
|
COMMENT ON FUNCTION auth.get_current_tenant IS 'Get current tenant from context';
|
||||||
|
COMMENT ON FUNCTION plans.check_limit IS 'Check if action is within plan limits';
|
||||||
|
COMMENT ON FUNCTION plans.has_feature IS 'Check if tenant has access to feature';
|
||||||
28
ddl/schemas/ai/_MAP.md
Normal file
28
ddl/schemas/ai/_MAP.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Schema: ai
|
||||||
|
|
||||||
|
AI Integration - Configuraciones, tracking de uso y rate limits.
|
||||||
|
|
||||||
|
## Tablas
|
||||||
|
|
||||||
|
| Archivo | Tabla | Descripcion |
|
||||||
|
|---------|-------|-------------|
|
||||||
|
| 01-ai-configs.sql | ai.configs | Configuracion AI por tenant |
|
||||||
|
| 02-ai-usage.sql | ai.usage | Tracking de uso de API calls |
|
||||||
|
|
||||||
|
## Enums (en 02-enums.sql)
|
||||||
|
|
||||||
|
- `ai.ai_provider` - openrouter, openai, anthropic, google
|
||||||
|
- `ai.ai_model_type` - chat, completion, embedding, image
|
||||||
|
- `ai.usage_status` - pending, completed, failed, cancelled
|
||||||
|
|
||||||
|
## Vistas
|
||||||
|
|
||||||
|
- `ai.monthly_usage` - Resumen mensual de uso por tenant
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
- `ai.get_current_month_usage(tenant_id)` - Uso del mes actual
|
||||||
|
|
||||||
|
## RLS Policies
|
||||||
|
|
||||||
|
Ambas tablas tienen Row Level Security habilitado con aislamiento por tenant.
|
||||||
68
ddl/schemas/ai/tables/01-ai-configs.sql
Normal file
68
ddl/schemas/ai/tables/01-ai-configs.sql
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- AI Configurations Table
|
||||||
|
-- Per-tenant AI settings and defaults
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE ai.configs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Provider settings
|
||||||
|
provider ai.ai_provider NOT NULL DEFAULT 'openrouter',
|
||||||
|
default_model VARCHAR(100) NOT NULL DEFAULT 'anthropic/claude-3-haiku',
|
||||||
|
fallback_model VARCHAR(100) DEFAULT 'openai/gpt-3.5-turbo',
|
||||||
|
|
||||||
|
-- Generation parameters
|
||||||
|
temperature NUMERIC(3,2) NOT NULL DEFAULT 0.7 CHECK (temperature >= 0 AND temperature <= 2),
|
||||||
|
max_tokens INTEGER NOT NULL DEFAULT 2048 CHECK (max_tokens > 0 AND max_tokens <= 32000),
|
||||||
|
top_p NUMERIC(3,2) DEFAULT 1.0 CHECK (top_p >= 0 AND top_p <= 1),
|
||||||
|
frequency_penalty NUMERIC(3,2) DEFAULT 0.0 CHECK (frequency_penalty >= -2 AND frequency_penalty <= 2),
|
||||||
|
presence_penalty NUMERIC(3,2) DEFAULT 0.0 CHECK (presence_penalty >= -2 AND presence_penalty <= 2),
|
||||||
|
|
||||||
|
-- System prompt
|
||||||
|
system_prompt TEXT,
|
||||||
|
|
||||||
|
-- Rate limits (override plan defaults)
|
||||||
|
rate_limit_requests_per_minute INTEGER,
|
||||||
|
rate_limit_tokens_per_minute INTEGER,
|
||||||
|
rate_limit_tokens_per_month INTEGER,
|
||||||
|
|
||||||
|
-- Feature flags
|
||||||
|
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
allow_custom_prompts BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
log_conversations BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
-- Additional settings as JSON
|
||||||
|
settings JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Ensure one config per tenant
|
||||||
|
CONSTRAINT uq_ai_configs_tenant UNIQUE (tenant_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_ai_configs_tenant ON ai.configs(tenant_id);
|
||||||
|
CREATE INDEX idx_ai_configs_provider ON ai.configs(provider);
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE ai.configs ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY ai_configs_tenant_isolation ON ai.configs
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Trigger for updated_at (uses function from 03-functions.sql)
|
||||||
|
-- Note: This trigger is created in 03-functions.sql after the function exists
|
||||||
|
-- CREATE TRIGGER update_ai_configs_updated_at
|
||||||
|
-- BEFORE UPDATE ON ai.configs
|
||||||
|
-- FOR EACH ROW
|
||||||
|
-- EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE ai.configs IS 'AI configuration per tenant';
|
||||||
|
COMMENT ON COLUMN ai.configs.default_model IS 'Default model to use (e.g., anthropic/claude-3-haiku)';
|
||||||
|
COMMENT ON COLUMN ai.configs.system_prompt IS 'Default system prompt for all AI interactions';
|
||||||
|
COMMENT ON COLUMN ai.configs.settings IS 'Additional provider-specific settings as JSON';
|
||||||
124
ddl/schemas/ai/tables/02-ai-usage.sql
Normal file
124
ddl/schemas/ai/tables/02-ai-usage.sql
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- AI Usage Tracking Table
|
||||||
|
-- Records each AI API call for billing/analytics
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE ai.usage (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Request details
|
||||||
|
provider ai.ai_provider NOT NULL,
|
||||||
|
model VARCHAR(100) NOT NULL,
|
||||||
|
model_type ai.ai_model_type NOT NULL DEFAULT 'chat',
|
||||||
|
status ai.usage_status NOT NULL DEFAULT 'pending',
|
||||||
|
|
||||||
|
-- Token counts
|
||||||
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER GENERATED ALWAYS AS (input_tokens + output_tokens) STORED,
|
||||||
|
|
||||||
|
-- Cost tracking (in USD, 6 decimal precision)
|
||||||
|
cost_input NUMERIC(12,6) NOT NULL DEFAULT 0,
|
||||||
|
cost_output NUMERIC(12,6) NOT NULL DEFAULT 0,
|
||||||
|
cost_total NUMERIC(12,6) GENERATED ALWAYS AS (cost_input + cost_output) STORED,
|
||||||
|
|
||||||
|
-- Performance metrics
|
||||||
|
latency_ms INTEGER,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Request metadata
|
||||||
|
request_id VARCHAR(100),
|
||||||
|
endpoint VARCHAR(50),
|
||||||
|
error_message TEXT,
|
||||||
|
|
||||||
|
-- Additional context
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for common queries
|
||||||
|
CREATE INDEX idx_ai_usage_tenant ON ai.usage(tenant_id);
|
||||||
|
CREATE INDEX idx_ai_usage_user ON ai.usage(user_id);
|
||||||
|
CREATE INDEX idx_ai_usage_tenant_created ON ai.usage(tenant_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_ai_usage_model ON ai.usage(model);
|
||||||
|
CREATE INDEX idx_ai_usage_status ON ai.usage(status);
|
||||||
|
CREATE INDEX idx_ai_usage_created_at ON ai.usage(created_at DESC);
|
||||||
|
|
||||||
|
-- Index for monthly queries (used by get_current_month_usage function)
|
||||||
|
-- Note: Partial index with CURRENT_DATE removed as it's not IMMUTABLE
|
||||||
|
CREATE INDEX idx_ai_usage_monthly ON ai.usage(tenant_id, created_at, status)
|
||||||
|
WHERE status = 'completed';
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE ai.usage ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY ai_usage_tenant_isolation ON ai.usage
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE ai.usage IS 'AI API usage tracking for billing and analytics';
|
||||||
|
COMMENT ON COLUMN ai.usage.input_tokens IS 'Number of input/prompt tokens';
|
||||||
|
COMMENT ON COLUMN ai.usage.output_tokens IS 'Number of output/completion tokens';
|
||||||
|
COMMENT ON COLUMN ai.usage.cost_input IS 'Cost for input tokens in USD';
|
||||||
|
COMMENT ON COLUMN ai.usage.cost_output IS 'Cost for output tokens in USD';
|
||||||
|
COMMENT ON COLUMN ai.usage.latency_ms IS 'Request latency in milliseconds';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Monthly Usage Summary View
|
||||||
|
-- For efficient billing queries
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE VIEW ai.monthly_usage AS
|
||||||
|
SELECT
|
||||||
|
tenant_id,
|
||||||
|
date_trunc('month', created_at) AS month,
|
||||||
|
COUNT(*) AS request_count,
|
||||||
|
SUM(input_tokens) AS total_input_tokens,
|
||||||
|
SUM(output_tokens) AS total_output_tokens,
|
||||||
|
SUM(input_tokens + output_tokens) AS total_tokens,
|
||||||
|
SUM(cost_input + cost_output) AS total_cost,
|
||||||
|
AVG(latency_ms) AS avg_latency_ms
|
||||||
|
FROM ai.usage
|
||||||
|
WHERE status = 'completed'
|
||||||
|
GROUP BY tenant_id, date_trunc('month', created_at);
|
||||||
|
|
||||||
|
COMMENT ON VIEW ai.monthly_usage IS 'Monthly AI usage aggregation per tenant';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Function: Get tenant usage for current month
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION ai.get_current_month_usage(p_tenant_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
request_count BIGINT,
|
||||||
|
total_input_tokens BIGINT,
|
||||||
|
total_output_tokens BIGINT,
|
||||||
|
total_tokens BIGINT,
|
||||||
|
total_cost NUMERIC,
|
||||||
|
avg_latency_ms NUMERIC
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::BIGINT,
|
||||||
|
COALESCE(SUM(input_tokens), 0)::BIGINT,
|
||||||
|
COALESCE(SUM(output_tokens), 0)::BIGINT,
|
||||||
|
COALESCE(SUM(input_tokens + output_tokens), 0)::BIGINT,
|
||||||
|
COALESCE(SUM(cost_input + cost_output), 0)::NUMERIC,
|
||||||
|
COALESCE(AVG(latency_ms), 0)::NUMERIC
|
||||||
|
FROM ai.usage
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND status = 'completed'
|
||||||
|
AND created_at >= date_trunc('month', CURRENT_DATE);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION ai.get_current_month_usage IS 'Get AI usage for current month for a tenant';
|
||||||
141
ddl/schemas/audit/tables/01-audit-logs.sql
Normal file
141
ddl/schemas/audit/tables/01-audit-logs.sql
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Audit Logs
|
||||||
|
-- Schema: audit
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE audit.audit_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Actor
|
||||||
|
user_id UUID REFERENCES users.users(id),
|
||||||
|
user_email VARCHAR(255),
|
||||||
|
actor_type VARCHAR(50) DEFAULT 'user', -- 'user', 'system', 'api_key', 'webhook'
|
||||||
|
|
||||||
|
-- Action
|
||||||
|
action audit.action_type NOT NULL,
|
||||||
|
resource_type VARCHAR(100) NOT NULL, -- 'user', 'product', 'invoice', etc.
|
||||||
|
resource_id VARCHAR(255),
|
||||||
|
resource_name VARCHAR(255),
|
||||||
|
|
||||||
|
-- Details
|
||||||
|
description TEXT,
|
||||||
|
changes JSONB, -- { "field": { "old": x, "new": y } }
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
|
-- Severity
|
||||||
|
severity audit.severity DEFAULT 'info',
|
||||||
|
|
||||||
|
-- Context
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
request_id VARCHAR(100),
|
||||||
|
session_id UUID,
|
||||||
|
|
||||||
|
-- Timestamp
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
|
||||||
|
-- Partitioning support (optional)
|
||||||
|
partition_key DATE DEFAULT CURRENT_DATE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Activity logs (lighter weight, for analytics)
|
||||||
|
CREATE TABLE audit.activity_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES users.users(id),
|
||||||
|
|
||||||
|
-- Activity
|
||||||
|
activity_type VARCHAR(100) NOT NULL, -- 'page_view', 'button_click', 'search', etc.
|
||||||
|
page_url VARCHAR(500),
|
||||||
|
referrer VARCHAR(500),
|
||||||
|
|
||||||
|
-- Context
|
||||||
|
session_id UUID,
|
||||||
|
device_type VARCHAR(50),
|
||||||
|
browser VARCHAR(100),
|
||||||
|
os VARCHAR(100),
|
||||||
|
|
||||||
|
-- Data
|
||||||
|
data JSONB DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
|
-- Timestamp
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_audit_logs_tenant ON audit.audit_logs(tenant_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_audit_logs_user ON audit.audit_logs(tenant_id, user_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_audit_logs_resource ON audit.audit_logs(tenant_id, resource_type, resource_id);
|
||||||
|
CREATE INDEX idx_audit_logs_action ON audit.audit_logs(tenant_id, action, created_at DESC);
|
||||||
|
CREATE INDEX idx_audit_logs_severity ON audit.audit_logs(tenant_id, severity) WHERE severity IN ('warning', 'error', 'critical');
|
||||||
|
|
||||||
|
CREATE INDEX idx_activity_logs_tenant ON audit.activity_logs(tenant_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_activity_logs_user ON audit.activity_logs(tenant_id, user_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_activity_logs_type ON audit.activity_logs(tenant_id, activity_type);
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE audit.audit_logs ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE audit.activity_logs ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY audit_logs_tenant_isolation ON audit.audit_logs
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
CREATE POLICY activity_logs_tenant_isolation ON audit.activity_logs
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Function to log audit event
|
||||||
|
CREATE OR REPLACE FUNCTION audit.log_event(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_user_id UUID,
|
||||||
|
p_action audit.action_type,
|
||||||
|
p_resource_type VARCHAR,
|
||||||
|
p_resource_id VARCHAR DEFAULT NULL,
|
||||||
|
p_resource_name VARCHAR DEFAULT NULL,
|
||||||
|
p_description TEXT DEFAULT NULL,
|
||||||
|
p_changes JSONB DEFAULT NULL,
|
||||||
|
p_metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
p_severity audit.severity DEFAULT 'info'
|
||||||
|
)
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_id UUID;
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO audit.audit_logs (
|
||||||
|
tenant_id, user_id, action, resource_type, resource_id,
|
||||||
|
resource_name, description, changes, metadata, severity
|
||||||
|
) VALUES (
|
||||||
|
p_tenant_id, p_user_id, p_action, p_resource_type, p_resource_id,
|
||||||
|
p_resource_name, p_description, p_changes, p_metadata, p_severity
|
||||||
|
) RETURNING id INTO v_id;
|
||||||
|
|
||||||
|
RETURN v_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Retention policy function
|
||||||
|
CREATE OR REPLACE FUNCTION audit.cleanup_old_logs(retention_days INT DEFAULT 90)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
deleted_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
WITH deleted AS (
|
||||||
|
DELETE FROM audit.audit_logs
|
||||||
|
WHERE created_at < NOW() - (retention_days || ' days')::INTERVAL
|
||||||
|
AND severity NOT IN ('error', 'critical')
|
||||||
|
RETURNING *
|
||||||
|
)
|
||||||
|
SELECT COUNT(*) INTO deleted_count FROM deleted;
|
||||||
|
|
||||||
|
DELETE FROM audit.activity_logs
|
||||||
|
WHERE created_at < NOW() - (retention_days || ' days')::INTERVAL;
|
||||||
|
|
||||||
|
RETURN deleted_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE audit.audit_logs IS 'Comprehensive audit trail for compliance';
|
||||||
|
COMMENT ON TABLE audit.activity_logs IS 'User activity tracking for analytics';
|
||||||
|
COMMENT ON COLUMN audit.audit_logs.changes IS 'JSON diff of field changes';
|
||||||
68
ddl/schemas/auth/tables/01-sessions.sql
Normal file
68
ddl/schemas/auth/tables/01-sessions.sql
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Sessions
|
||||||
|
-- Schema: auth
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE auth.sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Session identification
|
||||||
|
token_hash VARCHAR(255) UNIQUE NOT NULL, -- SHA256 of session token
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status auth.session_status DEFAULT 'active' NOT NULL,
|
||||||
|
|
||||||
|
-- Device/Client info
|
||||||
|
user_agent TEXT,
|
||||||
|
ip_address INET,
|
||||||
|
device_type VARCHAR(50), -- 'desktop', 'mobile', 'tablet'
|
||||||
|
device_name VARCHAR(200),
|
||||||
|
browser VARCHAR(100),
|
||||||
|
os VARCHAR(100),
|
||||||
|
location VARCHAR(200), -- City, Country
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
last_activity_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
revoked_reason VARCHAR(100)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_sessions_user ON auth.sessions(user_id) WHERE status = 'active';
|
||||||
|
CREATE INDEX idx_sessions_tenant ON auth.sessions(tenant_id) WHERE status = 'active';
|
||||||
|
CREATE INDEX idx_sessions_token ON auth.sessions(token_hash);
|
||||||
|
CREATE INDEX idx_sessions_expires ON auth.sessions(expires_at) WHERE status = 'active';
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE auth.sessions ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY sessions_tenant_isolation ON auth.sessions
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Function to clean expired sessions
|
||||||
|
CREATE OR REPLACE FUNCTION auth.cleanup_expired_sessions()
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
deleted_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
WITH deleted AS (
|
||||||
|
DELETE FROM auth.sessions
|
||||||
|
WHERE expires_at < NOW() - INTERVAL '7 days'
|
||||||
|
OR (status = 'revoked' AND revoked_at < NOW() - INTERVAL '30 days')
|
||||||
|
RETURNING *
|
||||||
|
)
|
||||||
|
SELECT COUNT(*) INTO deleted_count FROM deleted;
|
||||||
|
|
||||||
|
RETURN deleted_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE auth.sessions IS 'Active user sessions with device info';
|
||||||
|
COMMENT ON COLUMN auth.sessions.token_hash IS 'SHA256 hash of session token for secure storage';
|
||||||
|
COMMENT ON COLUMN auth.sessions.revoked_reason IS 'Reason for revocation: logout, security, password_change, admin';
|
||||||
88
ddl/schemas/auth/tables/02-tokens.sql
Normal file
88
ddl/schemas/auth/tables/02-tokens.sql
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Auth Tokens
|
||||||
|
-- Schema: auth
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Generic tokens (password reset, email verification, etc.)
|
||||||
|
CREATE TABLE auth.tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES tenants.tenants(id) ON DELETE CASCADE, -- NULL for pre-signup tokens
|
||||||
|
user_id UUID REFERENCES users.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Token data
|
||||||
|
token_hash VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
type auth.token_type NOT NULL,
|
||||||
|
|
||||||
|
-- For API keys
|
||||||
|
name VARCHAR(200), -- User-defined name
|
||||||
|
prefix VARCHAR(10), -- First chars for identification (e.g., "sk_live_")
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Scope/permissions (for API keys)
|
||||||
|
scopes JSONB DEFAULT '[]'::jsonb,
|
||||||
|
|
||||||
|
-- Expiration
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
is_revoked BOOLEAN DEFAULT FALSE,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
created_by UUID
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Refresh tokens (separate for better management)
|
||||||
|
CREATE TABLE auth.refresh_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
|
||||||
|
session_id UUID REFERENCES auth.sessions(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Token
|
||||||
|
token_hash VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
|
||||||
|
-- Rotation tracking
|
||||||
|
family_id UUID NOT NULL, -- For refresh token rotation
|
||||||
|
generation INT DEFAULT 1,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_revoked BOOLEAN DEFAULT FALSE,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Expiration
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_tokens_user ON auth.tokens(user_id) WHERE NOT is_revoked;
|
||||||
|
CREATE INDEX idx_tokens_type ON auth.tokens(type) WHERE NOT is_revoked;
|
||||||
|
CREATE INDEX idx_tokens_tenant ON auth.tokens(tenant_id) WHERE NOT is_revoked;
|
||||||
|
CREATE INDEX idx_tokens_api_key ON auth.tokens(prefix) WHERE type = 'api_key' AND NOT is_revoked;
|
||||||
|
|
||||||
|
CREATE INDEX idx_refresh_tokens_user ON auth.refresh_tokens(user_id) WHERE NOT is_revoked;
|
||||||
|
CREATE INDEX idx_refresh_tokens_family ON auth.refresh_tokens(family_id);
|
||||||
|
CREATE INDEX idx_refresh_tokens_session ON auth.refresh_tokens(session_id);
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE auth.tokens ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE auth.refresh_tokens ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tokens_tenant_isolation ON auth.tokens
|
||||||
|
USING (tenant_id IS NULL OR tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
CREATE POLICY refresh_tokens_tenant_isolation ON auth.refresh_tokens
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE auth.tokens IS 'Generic tokens for various auth purposes';
|
||||||
|
COMMENT ON TABLE auth.refresh_tokens IS 'JWT refresh tokens with rotation support';
|
||||||
|
COMMENT ON COLUMN auth.tokens.prefix IS 'Visible prefix for API key identification';
|
||||||
|
COMMENT ON COLUMN auth.refresh_tokens.family_id IS 'Group ID for refresh token rotation (detect reuse)';
|
||||||
70
ddl/schemas/auth/tables/03-oauth.sql
Normal file
70
ddl/schemas/auth/tables/03-oauth.sql
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: OAuth Connections
|
||||||
|
-- Schema: auth
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE auth.oauth_connections (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Provider info
|
||||||
|
provider auth.oauth_provider NOT NULL,
|
||||||
|
provider_user_id VARCHAR(255) NOT NULL,
|
||||||
|
|
||||||
|
-- Provider data
|
||||||
|
provider_email VARCHAR(255),
|
||||||
|
provider_name VARCHAR(255),
|
||||||
|
provider_avatar_url VARCHAR(500),
|
||||||
|
|
||||||
|
-- Tokens (encrypted at rest)
|
||||||
|
access_token TEXT,
|
||||||
|
refresh_token TEXT,
|
||||||
|
token_expires_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Scopes granted
|
||||||
|
scopes JSONB DEFAULT '[]'::jsonb,
|
||||||
|
|
||||||
|
-- Metadata from provider
|
||||||
|
raw_data JSONB DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT unique_provider_user UNIQUE (provider, provider_user_id),
|
||||||
|
CONSTRAINT unique_user_provider UNIQUE (user_id, provider)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_oauth_user ON auth.oauth_connections(user_id);
|
||||||
|
CREATE INDEX idx_oauth_provider ON auth.oauth_connections(provider, provider_user_id);
|
||||||
|
CREATE INDEX idx_oauth_tenant ON auth.oauth_connections(tenant_id);
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE auth.oauth_connections ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY oauth_tenant_isolation ON auth.oauth_connections
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Trigger
|
||||||
|
CREATE OR REPLACE FUNCTION auth.update_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_oauth_updated_at
|
||||||
|
BEFORE UPDATE ON auth.oauth_connections
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION auth.update_updated_at();
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE auth.oauth_connections IS 'OAuth provider connections per user';
|
||||||
|
COMMENT ON COLUMN auth.oauth_connections.access_token IS 'Encrypted OAuth access token';
|
||||||
|
COMMENT ON COLUMN auth.oauth_connections.raw_data IS 'Raw profile data from provider';
|
||||||
107
ddl/schemas/billing/tables/01-subscriptions.sql
Normal file
107
ddl/schemas/billing/tables/01-subscriptions.sql
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Subscriptions
|
||||||
|
-- Schema: billing
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE billing.subscriptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
plan_id UUID NOT NULL REFERENCES plans.plans(id),
|
||||||
|
|
||||||
|
-- Stripe
|
||||||
|
stripe_subscription_id VARCHAR(255) UNIQUE,
|
||||||
|
stripe_customer_id VARCHAR(255),
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status tenants.subscription_status DEFAULT 'trialing' NOT NULL,
|
||||||
|
|
||||||
|
-- Billing interval
|
||||||
|
interval billing.billing_interval DEFAULT 'month',
|
||||||
|
|
||||||
|
-- Current period
|
||||||
|
current_period_start TIMESTAMPTZ,
|
||||||
|
current_period_end TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Trial
|
||||||
|
trial_start TIMESTAMPTZ,
|
||||||
|
trial_end TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Cancellation
|
||||||
|
cancel_at TIMESTAMPTZ,
|
||||||
|
canceled_at TIMESTAMPTZ,
|
||||||
|
cancel_reason VARCHAR(500),
|
||||||
|
|
||||||
|
-- Pricing at subscription time
|
||||||
|
price_amount DECIMAL(10, 2),
|
||||||
|
currency VARCHAR(3) DEFAULT 'USD',
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT unique_active_subscription UNIQUE (tenant_id) -- One active sub per tenant
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Subscription items (for metered/usage-based)
|
||||||
|
CREATE TABLE billing.subscription_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
subscription_id UUID NOT NULL REFERENCES billing.subscriptions(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Stripe
|
||||||
|
stripe_subscription_item_id VARCHAR(255),
|
||||||
|
stripe_price_id VARCHAR(255),
|
||||||
|
|
||||||
|
-- Item details
|
||||||
|
product_name VARCHAR(200),
|
||||||
|
quantity INT DEFAULT 1,
|
||||||
|
|
||||||
|
-- Pricing
|
||||||
|
unit_amount DECIMAL(10, 2),
|
||||||
|
|
||||||
|
-- For metered billing
|
||||||
|
is_metered BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_subscriptions_tenant ON billing.subscriptions(tenant_id);
|
||||||
|
CREATE INDEX idx_subscriptions_stripe ON billing.subscriptions(stripe_subscription_id);
|
||||||
|
CREATE INDEX idx_subscriptions_status ON billing.subscriptions(status);
|
||||||
|
CREATE INDEX idx_subscription_items_sub ON billing.subscription_items(subscription_id);
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE billing.subscriptions ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE billing.subscription_items ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY subscriptions_tenant_isolation ON billing.subscriptions
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
CREATE POLICY subscription_items_tenant_isolation ON billing.subscription_items
|
||||||
|
USING (subscription_id IN (
|
||||||
|
SELECT id FROM billing.subscriptions
|
||||||
|
WHERE tenant_id = current_setting('app.current_tenant_id', true)::UUID
|
||||||
|
));
|
||||||
|
|
||||||
|
-- Trigger
|
||||||
|
CREATE OR REPLACE FUNCTION billing.update_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_subscriptions_updated_at
|
||||||
|
BEFORE UPDATE ON billing.subscriptions
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION billing.update_updated_at();
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE billing.subscriptions IS 'Active subscriptions per tenant';
|
||||||
|
COMMENT ON TABLE billing.subscription_items IS 'Line items within a subscription';
|
||||||
174
ddl/schemas/billing/tables/02-invoices.sql
Normal file
174
ddl/schemas/billing/tables/02-invoices.sql
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Invoices & Payments
|
||||||
|
-- Schema: billing
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE billing.invoices (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
subscription_id UUID REFERENCES billing.subscriptions(id),
|
||||||
|
|
||||||
|
-- Invoice number
|
||||||
|
invoice_number VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
|
||||||
|
-- Stripe
|
||||||
|
stripe_invoice_id VARCHAR(255) UNIQUE,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status billing.invoice_status DEFAULT 'draft' NOT NULL,
|
||||||
|
|
||||||
|
-- Amounts
|
||||||
|
subtotal DECIMAL(10, 2) NOT NULL DEFAULT 0,
|
||||||
|
tax DECIMAL(10, 2) DEFAULT 0,
|
||||||
|
discount DECIMAL(10, 2) DEFAULT 0,
|
||||||
|
total DECIMAL(10, 2) NOT NULL DEFAULT 0,
|
||||||
|
amount_paid DECIMAL(10, 2) DEFAULT 0,
|
||||||
|
amount_due DECIMAL(10, 2) NOT NULL DEFAULT 0,
|
||||||
|
currency VARCHAR(3) DEFAULT 'USD',
|
||||||
|
|
||||||
|
-- Dates
|
||||||
|
invoice_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
due_date DATE,
|
||||||
|
paid_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Period
|
||||||
|
period_start DATE,
|
||||||
|
period_end DATE,
|
||||||
|
|
||||||
|
-- PDF
|
||||||
|
invoice_pdf_url VARCHAR(500),
|
||||||
|
hosted_invoice_url VARCHAR(500),
|
||||||
|
|
||||||
|
-- Customer info at time of invoice
|
||||||
|
customer_name VARCHAR(255),
|
||||||
|
customer_email VARCHAR(255),
|
||||||
|
billing_address JSONB,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Invoice line items
|
||||||
|
CREATE TABLE billing.invoice_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
invoice_id UUID NOT NULL REFERENCES billing.invoices(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Description
|
||||||
|
description VARCHAR(500) NOT NULL,
|
||||||
|
quantity INT DEFAULT 1,
|
||||||
|
|
||||||
|
-- Pricing
|
||||||
|
unit_amount DECIMAL(10, 2) NOT NULL,
|
||||||
|
amount DECIMAL(10, 2) NOT NULL,
|
||||||
|
currency VARCHAR(3) DEFAULT 'USD',
|
||||||
|
|
||||||
|
-- Period (for subscription items)
|
||||||
|
period_start DATE,
|
||||||
|
period_end DATE,
|
||||||
|
|
||||||
|
-- Stripe
|
||||||
|
stripe_invoice_item_id VARCHAR(255),
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Payments
|
||||||
|
CREATE TABLE billing.payments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
invoice_id UUID REFERENCES billing.invoices(id),
|
||||||
|
|
||||||
|
-- Stripe
|
||||||
|
stripe_payment_intent_id VARCHAR(255) UNIQUE,
|
||||||
|
stripe_charge_id VARCHAR(255),
|
||||||
|
|
||||||
|
-- Amount
|
||||||
|
amount DECIMAL(10, 2) NOT NULL,
|
||||||
|
currency VARCHAR(3) DEFAULT 'USD',
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status billing.payment_status DEFAULT 'pending' NOT NULL,
|
||||||
|
|
||||||
|
-- Payment method
|
||||||
|
payment_method_type VARCHAR(50), -- 'card', 'bank_transfer', etc.
|
||||||
|
payment_method_last4 VARCHAR(4),
|
||||||
|
payment_method_brand VARCHAR(50),
|
||||||
|
|
||||||
|
-- Failure info
|
||||||
|
failure_code VARCHAR(100),
|
||||||
|
failure_message TEXT,
|
||||||
|
|
||||||
|
-- Dates
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
succeeded_at TIMESTAMPTZ,
|
||||||
|
failed_at TIMESTAMPTZ,
|
||||||
|
refunded_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Refund info
|
||||||
|
refund_amount DECIMAL(10, 2),
|
||||||
|
refund_reason VARCHAR(500)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_invoices_tenant ON billing.invoices(tenant_id);
|
||||||
|
CREATE INDEX idx_invoices_subscription ON billing.invoices(subscription_id);
|
||||||
|
CREATE INDEX idx_invoices_status ON billing.invoices(status);
|
||||||
|
CREATE INDEX idx_invoices_stripe ON billing.invoices(stripe_invoice_id);
|
||||||
|
CREATE INDEX idx_invoice_items_invoice ON billing.invoice_items(invoice_id);
|
||||||
|
CREATE INDEX idx_payments_tenant ON billing.payments(tenant_id);
|
||||||
|
CREATE INDEX idx_payments_invoice ON billing.payments(invoice_id);
|
||||||
|
CREATE INDEX idx_payments_stripe ON billing.payments(stripe_payment_intent_id);
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE billing.invoices ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE billing.invoice_items ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE billing.payments ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY invoices_tenant_isolation ON billing.invoices
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
CREATE POLICY invoice_items_tenant_isolation ON billing.invoice_items
|
||||||
|
USING (invoice_id IN (
|
||||||
|
SELECT id FROM billing.invoices
|
||||||
|
WHERE tenant_id = current_setting('app.current_tenant_id', true)::UUID
|
||||||
|
));
|
||||||
|
|
||||||
|
CREATE POLICY payments_tenant_isolation ON billing.payments
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Trigger
|
||||||
|
CREATE TRIGGER trg_invoices_updated_at
|
||||||
|
BEFORE UPDATE ON billing.invoices
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION billing.update_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_payments_updated_at
|
||||||
|
BEFORE UPDATE ON billing.payments
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION billing.update_updated_at();
|
||||||
|
|
||||||
|
-- Invoice number sequence
|
||||||
|
CREATE SEQUENCE billing.invoice_number_seq START 1000;
|
||||||
|
|
||||||
|
-- Function to generate invoice number
|
||||||
|
CREATE OR REPLACE FUNCTION billing.generate_invoice_number()
|
||||||
|
RETURNS VARCHAR AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN 'INV-' || TO_CHAR(NOW(), 'YYYYMM') || '-' || LPAD(nextval('billing.invoice_number_seq')::TEXT, 6, '0');
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE billing.invoices IS 'Invoices generated for billing';
|
||||||
|
COMMENT ON TABLE billing.invoice_items IS 'Line items within an invoice';
|
||||||
|
COMMENT ON TABLE billing.payments IS 'Payment records and attempts';
|
||||||
55
ddl/schemas/billing/tables/03-payment-methods.sql
Normal file
55
ddl/schemas/billing/tables/03-payment-methods.sql
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- BILLING: Payment Methods Table
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS billing.payment_methods (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Type and provider
|
||||||
|
type billing.payment_method_type NOT NULL DEFAULT 'card',
|
||||||
|
payment_provider VARCHAR(50), -- stripe, conekta, etc.
|
||||||
|
external_payment_method_id VARCHAR(255), -- ID from provider
|
||||||
|
|
||||||
|
-- Card details (masked)
|
||||||
|
card_last_four VARCHAR(4),
|
||||||
|
card_brand VARCHAR(20), -- visa, mastercard, amex
|
||||||
|
card_exp_month SMALLINT,
|
||||||
|
card_exp_year SMALLINT,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_payment_methods_tenant ON billing.payment_methods(tenant_id);
|
||||||
|
CREATE INDEX idx_payment_methods_default ON billing.payment_methods(tenant_id, is_default) WHERE is_default = TRUE;
|
||||||
|
CREATE INDEX idx_payment_methods_active ON billing.payment_methods(tenant_id, is_active) WHERE is_active = TRUE;
|
||||||
|
CREATE INDEX idx_payment_methods_provider ON billing.payment_methods(external_payment_method_id) WHERE external_payment_method_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Row Level Security
|
||||||
|
ALTER TABLE billing.payment_methods ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY payment_methods_tenant_isolation ON billing.payment_methods
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Updated at trigger
|
||||||
|
CREATE TRIGGER trg_payment_methods_updated_at
|
||||||
|
BEFORE UPDATE ON billing.payment_methods
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION billing.update_updated_at();
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE billing.payment_methods IS 'Stored payment methods for tenants';
|
||||||
|
COMMENT ON COLUMN billing.payment_methods.type IS 'Type: card, bank_transfer, oxxo';
|
||||||
|
COMMENT ON COLUMN billing.payment_methods.external_payment_method_id IS 'Payment method ID from payment provider';
|
||||||
|
COMMENT ON COLUMN billing.payment_methods.is_default IS 'Whether this is the default payment method';
|
||||||
|
COMMENT ON COLUMN billing.payment_methods.is_active IS 'Whether this payment method can be used';
|
||||||
230
ddl/schemas/feature_flags/tables/01-flags.sql
Normal file
230
ddl/schemas/feature_flags/tables/01-flags.sql
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Feature Flags
|
||||||
|
-- Schema: feature_flags
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Feature flag definitions (global)
|
||||||
|
CREATE TABLE feature_flags.flags (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Flag identification
|
||||||
|
code VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category VARCHAR(100), -- 'billing', 'ui', 'api', 'experimental'
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status feature_flags.flag_status DEFAULT 'disabled' NOT NULL,
|
||||||
|
|
||||||
|
-- Rollout
|
||||||
|
rollout_stage feature_flags.rollout_stage DEFAULT 'development',
|
||||||
|
rollout_percentage INT DEFAULT 0, -- 0-100
|
||||||
|
|
||||||
|
-- Default value
|
||||||
|
default_value BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Targeting rules (JSONB)
|
||||||
|
targeting_rules JSONB DEFAULT '[]'::jsonb,
|
||||||
|
-- Example:
|
||||||
|
-- [
|
||||||
|
-- { "type": "plan", "operator": "in", "values": ["pro", "enterprise"] },
|
||||||
|
-- { "type": "user_attribute", "attribute": "country", "operator": "eq", "value": "MX" }
|
||||||
|
-- ]
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
tags JSONB DEFAULT '[]'::jsonb,
|
||||||
|
|
||||||
|
-- Lifecycle
|
||||||
|
is_permanent BOOLEAN DEFAULT FALSE, -- Won't be cleaned up
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
created_by VARCHAR(100),
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT valid_flag_code CHECK (code ~ '^[a-z][a-z0-9_]*$'),
|
||||||
|
CONSTRAINT valid_percentage CHECK (rollout_percentage >= 0 AND rollout_percentage <= 100)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tenant-specific flag overrides
|
||||||
|
CREATE TABLE feature_flags.tenant_flags (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
flag_id UUID NOT NULL REFERENCES feature_flags.flags(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Override value
|
||||||
|
is_enabled BOOLEAN NOT NULL,
|
||||||
|
|
||||||
|
-- Reason
|
||||||
|
reason VARCHAR(500),
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
updated_by UUID,
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT unique_tenant_flag UNIQUE (tenant_id, flag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User-specific flag overrides
|
||||||
|
CREATE TABLE feature_flags.user_flags (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
|
||||||
|
flag_id UUID NOT NULL REFERENCES feature_flags.flags(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Override value
|
||||||
|
is_enabled BOOLEAN NOT NULL,
|
||||||
|
|
||||||
|
-- Reason
|
||||||
|
reason VARCHAR(500),
|
||||||
|
|
||||||
|
-- Expiration
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
updated_by UUID,
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT unique_user_flag UNIQUE (user_id, flag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Flag evaluation history (for analytics)
|
||||||
|
CREATE TABLE feature_flags.evaluations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
flag_id UUID NOT NULL REFERENCES feature_flags.flags(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES users.users(id),
|
||||||
|
|
||||||
|
-- Result
|
||||||
|
flag_code VARCHAR(100) NOT NULL,
|
||||||
|
result BOOLEAN NOT NULL,
|
||||||
|
evaluation_reason VARCHAR(100), -- 'default', 'tenant_override', 'user_override', 'targeting_rule', 'percentage'
|
||||||
|
|
||||||
|
-- Context
|
||||||
|
context JSONB DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
|
-- Timestamp
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_flags_code ON feature_flags.flags(code);
|
||||||
|
CREATE INDEX idx_flags_status ON feature_flags.flags(status) WHERE status != 'disabled';
|
||||||
|
CREATE INDEX idx_flags_category ON feature_flags.flags(category);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tenant_flags_tenant ON feature_flags.tenant_flags(tenant_id);
|
||||||
|
CREATE INDEX idx_tenant_flags_flag ON feature_flags.tenant_flags(flag_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_flags_user ON feature_flags.user_flags(user_id);
|
||||||
|
CREATE INDEX idx_user_flags_tenant ON feature_flags.user_flags(tenant_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_evaluations_tenant ON feature_flags.evaluations(tenant_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_evaluations_flag ON feature_flags.evaluations(flag_id, created_at DESC);
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE feature_flags.tenant_flags ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE feature_flags.user_flags ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE feature_flags.evaluations ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_flags_isolation ON feature_flags.tenant_flags
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
CREATE POLICY user_flags_isolation ON feature_flags.user_flags
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
CREATE POLICY evaluations_isolation ON feature_flags.evaluations
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Trigger
|
||||||
|
CREATE OR REPLACE FUNCTION feature_flags.update_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_flags_updated_at
|
||||||
|
BEFORE UPDATE ON feature_flags.flags
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION feature_flags.update_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_tenant_flags_updated_at
|
||||||
|
BEFORE UPDATE ON feature_flags.tenant_flags
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION feature_flags.update_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_user_flags_updated_at
|
||||||
|
BEFORE UPDATE ON feature_flags.user_flags
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION feature_flags.update_updated_at();
|
||||||
|
|
||||||
|
-- Function to evaluate flag
|
||||||
|
CREATE OR REPLACE FUNCTION feature_flags.evaluate_flag(
|
||||||
|
p_flag_code VARCHAR,
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_user_id UUID DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
DECLARE
|
||||||
|
v_flag RECORD;
|
||||||
|
v_tenant_override RECORD;
|
||||||
|
v_user_override RECORD;
|
||||||
|
v_result BOOLEAN;
|
||||||
|
BEGIN
|
||||||
|
-- Get flag
|
||||||
|
SELECT * INTO v_flag FROM feature_flags.flags WHERE code = p_flag_code;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Check user override
|
||||||
|
IF p_user_id IS NOT NULL THEN
|
||||||
|
SELECT * INTO v_user_override
|
||||||
|
FROM feature_flags.user_flags
|
||||||
|
WHERE flag_id = v_flag.id AND user_id = p_user_id
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW());
|
||||||
|
|
||||||
|
IF FOUND THEN
|
||||||
|
RETURN v_user_override.is_enabled;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Check tenant override
|
||||||
|
SELECT * INTO v_tenant_override
|
||||||
|
FROM feature_flags.tenant_flags
|
||||||
|
WHERE flag_id = v_flag.id AND tenant_id = p_tenant_id;
|
||||||
|
|
||||||
|
IF FOUND THEN
|
||||||
|
RETURN v_tenant_override.is_enabled;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Check status
|
||||||
|
IF v_flag.status = 'disabled' THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
ELSIF v_flag.status = 'enabled' THEN
|
||||||
|
RETURN TRUE;
|
||||||
|
ELSIF v_flag.status = 'percentage' THEN
|
||||||
|
-- Simple percentage rollout based on tenant_id
|
||||||
|
RETURN (abs(hashtext(p_tenant_id::TEXT)) % 100) < v_flag.rollout_percentage;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN v_flag.default_value;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE feature_flags.flags IS 'Global feature flag definitions';
|
||||||
|
COMMENT ON TABLE feature_flags.tenant_flags IS 'Tenant-specific flag overrides';
|
||||||
|
COMMENT ON TABLE feature_flags.user_flags IS 'User-specific flag overrides';
|
||||||
|
COMMENT ON TABLE feature_flags.evaluations IS 'Flag evaluation history for analytics';
|
||||||
|
COMMENT ON COLUMN feature_flags.flags.targeting_rules IS 'JSON rules for advanced targeting';
|
||||||
160
ddl/schemas/notifications/tables/01-notifications.sql
Normal file
160
ddl/schemas/notifications/tables/01-notifications.sql
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Notifications
|
||||||
|
-- Schema: notifications
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Notification templates
|
||||||
|
CREATE TABLE notifications.templates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Template identification
|
||||||
|
code VARCHAR(100) UNIQUE NOT NULL, -- e.g., 'welcome_email', 'invoice_paid'
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category VARCHAR(100), -- 'transactional', 'marketing', 'system'
|
||||||
|
|
||||||
|
-- Channel
|
||||||
|
channel notifications.channel NOT NULL,
|
||||||
|
|
||||||
|
-- Content
|
||||||
|
subject VARCHAR(500), -- For email
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
body_html TEXT, -- For email HTML version
|
||||||
|
|
||||||
|
-- Variables (for template rendering)
|
||||||
|
variables JSONB DEFAULT '[]'::jsonb,
|
||||||
|
-- Example: [{"name": "user_name", "required": true}, {"name": "company", "default": "SaaS"}]
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Notifications (instances)
|
||||||
|
CREATE TABLE notifications.notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES users.users(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Template
|
||||||
|
template_id UUID REFERENCES notifications.templates(id),
|
||||||
|
template_code VARCHAR(100),
|
||||||
|
|
||||||
|
-- Channel
|
||||||
|
channel notifications.channel NOT NULL,
|
||||||
|
|
||||||
|
-- Content
|
||||||
|
subject VARCHAR(500),
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
body_html TEXT,
|
||||||
|
|
||||||
|
-- Recipient
|
||||||
|
recipient_email VARCHAR(255),
|
||||||
|
recipient_phone VARCHAR(50),
|
||||||
|
recipient_device_token VARCHAR(500),
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status notifications.notification_status DEFAULT 'pending' NOT NULL,
|
||||||
|
priority notifications.priority DEFAULT 'normal',
|
||||||
|
|
||||||
|
-- Delivery info
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
delivered_at TIMESTAMPTZ,
|
||||||
|
is_read BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
read_at TIMESTAMPTZ,
|
||||||
|
failed_at TIMESTAMPTZ,
|
||||||
|
failure_reason TEXT,
|
||||||
|
|
||||||
|
-- Retry
|
||||||
|
retry_count INT DEFAULT 0,
|
||||||
|
next_retry_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User preferences
|
||||||
|
CREATE TABLE notifications.user_preferences (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Channel preferences
|
||||||
|
email_enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
push_enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
sms_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
in_app_enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Category preferences (JSONB)
|
||||||
|
category_preferences JSONB DEFAULT '{}'::jsonb,
|
||||||
|
-- Example: { "marketing": false, "transactional": true }
|
||||||
|
|
||||||
|
-- Quiet hours
|
||||||
|
quiet_hours_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
quiet_hours_start TIME,
|
||||||
|
quiet_hours_end TIME,
|
||||||
|
quiet_hours_timezone VARCHAR(50),
|
||||||
|
|
||||||
|
-- Digest
|
||||||
|
digest_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
digest_frequency VARCHAR(20), -- 'daily', 'weekly'
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT unique_user_preferences UNIQUE (user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_templates_code ON notifications.templates(code) WHERE is_active = TRUE;
|
||||||
|
CREATE INDEX idx_templates_channel ON notifications.templates(channel) WHERE is_active = TRUE;
|
||||||
|
|
||||||
|
CREATE INDEX idx_notifications_tenant ON notifications.notifications(tenant_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_notifications_user ON notifications.notifications(user_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_notifications_status ON notifications.notifications(status) WHERE status IN ('pending', 'sent');
|
||||||
|
CREATE INDEX idx_notifications_retry ON notifications.notifications(next_retry_at) WHERE status = 'pending' AND next_retry_at IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_preferences_user ON notifications.user_preferences(user_id);
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE notifications.notifications ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE notifications.user_preferences ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY notifications_tenant_isolation ON notifications.notifications
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
CREATE POLICY user_preferences_tenant_isolation ON notifications.user_preferences
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Trigger
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.update_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_templates_updated_at
|
||||||
|
BEFORE UPDATE ON notifications.templates
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION notifications.update_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_user_preferences_updated_at
|
||||||
|
BEFORE UPDATE ON notifications.user_preferences
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION notifications.update_updated_at();
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE notifications.templates IS 'Notification templates with variables';
|
||||||
|
COMMENT ON TABLE notifications.notifications IS 'Notification instances and delivery status';
|
||||||
|
COMMENT ON TABLE notifications.user_preferences IS 'User notification preferences';
|
||||||
506
ddl/schemas/notifications/tables/02-extended-notifications.sql
Normal file
506
ddl/schemas/notifications/tables/02-extended-notifications.sql
Normal file
@ -0,0 +1,506 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Notifications Extended Tables
|
||||||
|
-- Schema: notifications
|
||||||
|
-- Version: 2.0.0
|
||||||
|
-- Fecha: 2026-01-07
|
||||||
|
-- Basado en: ET-SAAS-007-notifications-v2
|
||||||
|
-- Nota: Enums definidos en 02-enums.sql
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- TABLA: user_devices
|
||||||
|
-- Dispositivos registrados para push notifications
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications.user_devices (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Device info
|
||||||
|
device_type notifications.device_type NOT NULL DEFAULT 'web',
|
||||||
|
device_token TEXT NOT NULL, -- PushSubscription JSON para Web Push
|
||||||
|
device_name VARCHAR(100), -- "Chrome en Windows", "Safari en macOS"
|
||||||
|
|
||||||
|
-- Browser/OS info
|
||||||
|
browser VARCHAR(50),
|
||||||
|
browser_version VARCHAR(20),
|
||||||
|
os VARCHAR(50),
|
||||||
|
os_version VARCHAR(20),
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
last_used_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT unique_user_device_token UNIQUE (user_id, device_token)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_devices_user ON notifications.user_devices(user_id) WHERE is_active = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_devices_tenant ON notifications.user_devices(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_devices_active ON notifications.user_devices(is_active, last_used_at DESC);
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE notifications.user_devices ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS user_devices_tenant_isolation ON notifications.user_devices;
|
||||||
|
CREATE POLICY user_devices_tenant_isolation ON notifications.user_devices
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
COMMENT ON TABLE notifications.user_devices IS 'Dispositivos registrados para push notifications (Web Push API)';
|
||||||
|
COMMENT ON COLUMN notifications.user_devices.device_token IS 'JSON serializado de PushSubscription del navegador';
|
||||||
|
COMMENT ON COLUMN notifications.user_devices.device_name IS 'Nombre descriptivo del dispositivo para UI';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- TABLA: notification_queue
|
||||||
|
-- Cola de procesamiento asincrono de notificaciones
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications.notification_queue (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
notification_id UUID NOT NULL REFERENCES notifications.notifications(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Channel
|
||||||
|
channel notifications.channel NOT NULL,
|
||||||
|
|
||||||
|
-- Scheduling
|
||||||
|
scheduled_for TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Priority (numerico para ordenamiento)
|
||||||
|
priority_value INT DEFAULT 0, -- urgent=10, high=5, normal=0, low=-5
|
||||||
|
|
||||||
|
-- Processing
|
||||||
|
attempts INT DEFAULT 0,
|
||||||
|
max_attempts INT DEFAULT 3,
|
||||||
|
status notifications.queue_status DEFAULT 'queued',
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
last_attempt_at TIMESTAMPTZ,
|
||||||
|
next_retry_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Error tracking
|
||||||
|
error_message TEXT,
|
||||||
|
error_count INT DEFAULT 0,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes para procesamiento eficiente
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_queue_pending ON notifications.notification_queue(scheduled_for, priority_value DESC)
|
||||||
|
WHERE status IN ('queued', 'retrying');
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_queue_status ON notifications.notification_queue(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_queue_notification ON notifications.notification_queue(notification_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_queue_retry ON notifications.notification_queue(next_retry_at)
|
||||||
|
WHERE status = 'retrying' AND next_retry_at IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_queue_created ON notifications.notification_queue(created_at DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE notifications.notification_queue IS 'Cola de procesamiento asincrono de notificaciones con BullMQ';
|
||||||
|
COMMENT ON COLUMN notifications.notification_queue.priority_value IS 'Valor numerico: urgent=10, high=5, normal=0, low=-5';
|
||||||
|
COMMENT ON COLUMN notifications.notification_queue.max_attempts IS 'Maximo de reintentos antes de marcar como failed';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- TABLA: notification_logs
|
||||||
|
-- Historial de entregas y eventos de notificaciones
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications.notification_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
notification_id UUID NOT NULL REFERENCES notifications.notifications(id) ON DELETE CASCADE,
|
||||||
|
queue_id UUID REFERENCES notifications.notification_queue(id),
|
||||||
|
|
||||||
|
-- Channel info
|
||||||
|
channel notifications.channel NOT NULL,
|
||||||
|
|
||||||
|
-- Delivery status
|
||||||
|
status VARCHAR(30) NOT NULL, -- 'sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained', 'failed'
|
||||||
|
|
||||||
|
-- Provider info
|
||||||
|
provider VARCHAR(50), -- 'sendgrid', 'ses', 'web-push', 'smtp'
|
||||||
|
provider_message_id VARCHAR(200),
|
||||||
|
provider_response JSONB,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
delivered_at TIMESTAMPTZ,
|
||||||
|
opened_at TIMESTAMPTZ,
|
||||||
|
clicked_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Error details
|
||||||
|
error_code VARCHAR(50),
|
||||||
|
error_message TEXT,
|
||||||
|
|
||||||
|
-- Device info (for push)
|
||||||
|
device_id UUID REFERENCES notifications.user_devices(id),
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_logs_notification ON notifications.notification_logs(notification_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_logs_status ON notifications.notification_logs(status, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_logs_channel ON notifications.notification_logs(channel, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_logs_provider ON notifications.notification_logs(provider, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_logs_queue ON notifications.notification_logs(queue_id) WHERE queue_id IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON TABLE notifications.notification_logs IS 'Historial de entregas con tracking detallado por proveedor';
|
||||||
|
COMMENT ON COLUMN notifications.notification_logs.status IS 'sent, delivered, opened, clicked, bounced, complained, failed';
|
||||||
|
COMMENT ON COLUMN notifications.notification_logs.provider_response IS 'Respuesta cruda del proveedor para debugging';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ACTUALIZAR user_preferences
|
||||||
|
-- Agregar campo para preferencias granulares
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Agregar columna para preferencias por tipo de notificacion
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'notifications'
|
||||||
|
AND table_name = 'user_preferences'
|
||||||
|
AND column_name = 'notification_type_preferences'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE notifications.user_preferences
|
||||||
|
ADD COLUMN notification_type_preferences JSONB DEFAULT '{}'::jsonb;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN notifications.user_preferences.notification_type_preferences
|
||||||
|
IS 'Preferencias por tipo: {"billing": {"email": true, "push": false}, "system": {"email": true}}';
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Agregar columna para frequencia de email digest
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'notifications'
|
||||||
|
AND table_name = 'user_preferences'
|
||||||
|
AND column_name = 'email_frequency'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE notifications.user_preferences
|
||||||
|
ADD COLUMN email_frequency VARCHAR(20) DEFAULT 'immediate';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN notifications.user_preferences.email_frequency
|
||||||
|
IS 'Frecuencia: immediate, daily, weekly';
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- FUNCIONES SQL
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Funcion: Calcular priority value desde string
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.get_priority_value(p_priority VARCHAR)
|
||||||
|
RETURNS INT AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN CASE p_priority
|
||||||
|
WHEN 'urgent' THEN 10
|
||||||
|
WHEN 'high' THEN 5
|
||||||
|
WHEN 'normal' THEN 0
|
||||||
|
WHEN 'low' THEN -5
|
||||||
|
ELSE 0
|
||||||
|
END;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION notifications.get_priority_value IS 'Convierte priority string a valor numerico para ordenamiento';
|
||||||
|
|
||||||
|
-- Funcion: Encolar notificacion
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.enqueue_notification(
|
||||||
|
p_notification_id UUID,
|
||||||
|
p_channel notifications.channel,
|
||||||
|
p_priority VARCHAR DEFAULT 'normal',
|
||||||
|
p_scheduled_for TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
) RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_queue_id UUID;
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO notifications.notification_queue (
|
||||||
|
notification_id,
|
||||||
|
channel,
|
||||||
|
priority_value,
|
||||||
|
scheduled_for,
|
||||||
|
status
|
||||||
|
) VALUES (
|
||||||
|
p_notification_id,
|
||||||
|
p_channel,
|
||||||
|
notifications.get_priority_value(p_priority),
|
||||||
|
p_scheduled_for,
|
||||||
|
'queued'
|
||||||
|
) RETURNING id INTO v_queue_id;
|
||||||
|
|
||||||
|
RETURN v_queue_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION notifications.enqueue_notification IS 'Encola notificacion para procesamiento asincrono';
|
||||||
|
|
||||||
|
-- Funcion: Obtener items pendientes de la cola (con lock)
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.get_pending_queue_items(
|
||||||
|
p_limit INT DEFAULT 100,
|
||||||
|
p_channel notifications.channel DEFAULT NULL
|
||||||
|
) RETURNS TABLE (
|
||||||
|
id UUID,
|
||||||
|
notification_id UUID,
|
||||||
|
channel notifications.channel,
|
||||||
|
priority_value INT,
|
||||||
|
attempts INT,
|
||||||
|
user_id UUID,
|
||||||
|
tenant_id UUID,
|
||||||
|
subject VARCHAR,
|
||||||
|
body TEXT,
|
||||||
|
body_html TEXT,
|
||||||
|
recipient_email VARCHAR,
|
||||||
|
metadata JSONB
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
WITH pending AS (
|
||||||
|
SELECT q.*
|
||||||
|
FROM notifications.notification_queue q
|
||||||
|
WHERE q.status IN ('queued', 'retrying')
|
||||||
|
AND (q.scheduled_for IS NULL OR q.scheduled_for <= NOW())
|
||||||
|
AND (q.next_retry_at IS NULL OR q.next_retry_at <= NOW())
|
||||||
|
AND (p_channel IS NULL OR q.channel = p_channel)
|
||||||
|
ORDER BY q.priority_value DESC, q.created_at ASC
|
||||||
|
LIMIT p_limit
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.notification_id,
|
||||||
|
p.channel,
|
||||||
|
p.priority_value,
|
||||||
|
p.attempts,
|
||||||
|
n.user_id,
|
||||||
|
n.tenant_id,
|
||||||
|
n.subject,
|
||||||
|
n.body,
|
||||||
|
n.body_html,
|
||||||
|
n.recipient_email,
|
||||||
|
n.metadata
|
||||||
|
FROM pending p
|
||||||
|
JOIN notifications.notifications n ON n.id = p.notification_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION notifications.get_pending_queue_items IS 'Obtiene items pendientes con FOR UPDATE SKIP LOCKED para procesamiento concurrente';
|
||||||
|
|
||||||
|
-- Funcion: Completar item de cola
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.complete_queue_item(
|
||||||
|
p_queue_id UUID,
|
||||||
|
p_status VARCHAR,
|
||||||
|
p_error_message TEXT DEFAULT NULL,
|
||||||
|
p_provider VARCHAR DEFAULT NULL,
|
||||||
|
p_provider_message_id VARCHAR DEFAULT NULL,
|
||||||
|
p_provider_response JSONB DEFAULT NULL
|
||||||
|
) RETURNS VOID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_queue RECORD;
|
||||||
|
v_notification_id UUID;
|
||||||
|
v_channel notifications.channel;
|
||||||
|
BEGIN
|
||||||
|
-- Obtener datos del queue item
|
||||||
|
SELECT q.*, n.id as notif_id
|
||||||
|
INTO v_queue
|
||||||
|
FROM notifications.notification_queue q
|
||||||
|
JOIN notifications.notifications n ON n.id = q.notification_id
|
||||||
|
WHERE q.id = p_queue_id;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION 'Queue item % not found', p_queue_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_notification_id := v_queue.notification_id;
|
||||||
|
v_channel := v_queue.channel;
|
||||||
|
|
||||||
|
IF p_status = 'sent' THEN
|
||||||
|
-- Marcar como enviado
|
||||||
|
UPDATE notifications.notification_queue
|
||||||
|
SET status = 'sent',
|
||||||
|
completed_at = NOW(),
|
||||||
|
last_attempt_at = NOW(),
|
||||||
|
attempts = attempts + 1
|
||||||
|
WHERE id = p_queue_id;
|
||||||
|
|
||||||
|
-- Actualizar notificacion
|
||||||
|
UPDATE notifications.notifications
|
||||||
|
SET status = 'sent',
|
||||||
|
sent_at = NOW()
|
||||||
|
WHERE id = v_notification_id
|
||||||
|
AND status = 'pending';
|
||||||
|
|
||||||
|
-- Crear log de exito
|
||||||
|
INSERT INTO notifications.notification_logs (
|
||||||
|
notification_id, queue_id, channel, status,
|
||||||
|
provider, provider_message_id, provider_response,
|
||||||
|
delivered_at
|
||||||
|
) VALUES (
|
||||||
|
v_notification_id, p_queue_id, v_channel, 'sent',
|
||||||
|
p_provider, p_provider_message_id, p_provider_response,
|
||||||
|
NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
ELSIF p_status = 'failed' THEN
|
||||||
|
IF v_queue.attempts + 1 >= v_queue.max_attempts THEN
|
||||||
|
-- Fallo definitivo
|
||||||
|
UPDATE notifications.notification_queue
|
||||||
|
SET status = 'failed',
|
||||||
|
last_attempt_at = NOW(),
|
||||||
|
attempts = attempts + 1,
|
||||||
|
error_message = p_error_message,
|
||||||
|
error_count = error_count + 1
|
||||||
|
WHERE id = p_queue_id;
|
||||||
|
|
||||||
|
-- Actualizar notificacion
|
||||||
|
UPDATE notifications.notifications
|
||||||
|
SET status = 'failed',
|
||||||
|
failed_at = NOW(),
|
||||||
|
failure_reason = p_error_message
|
||||||
|
WHERE id = v_notification_id;
|
||||||
|
ELSE
|
||||||
|
-- Programar retry con backoff exponencial (1, 2, 4, 8... minutos)
|
||||||
|
UPDATE notifications.notification_queue
|
||||||
|
SET status = 'retrying',
|
||||||
|
last_attempt_at = NOW(),
|
||||||
|
attempts = attempts + 1,
|
||||||
|
next_retry_at = NOW() + (POWER(2, v_queue.attempts) * INTERVAL '1 minute'),
|
||||||
|
error_message = p_error_message,
|
||||||
|
error_count = error_count + 1
|
||||||
|
WHERE id = p_queue_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Crear log de error
|
||||||
|
INSERT INTO notifications.notification_logs (
|
||||||
|
notification_id, queue_id, channel, status,
|
||||||
|
provider, error_message
|
||||||
|
) VALUES (
|
||||||
|
v_notification_id, p_queue_id, v_channel, 'failed',
|
||||||
|
p_provider, p_error_message
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION notifications.complete_queue_item IS 'Marca item como completado o fallido, con retry automatico';
|
||||||
|
|
||||||
|
-- Funcion: Limpiar notificaciones antiguas
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.cleanup_old_notifications(
|
||||||
|
p_days_to_keep INT DEFAULT 30
|
||||||
|
) RETURNS INT AS $$
|
||||||
|
DECLARE
|
||||||
|
v_deleted INT;
|
||||||
|
BEGIN
|
||||||
|
WITH deleted AS (
|
||||||
|
DELETE FROM notifications.notifications
|
||||||
|
WHERE created_at < NOW() - (p_days_to_keep || ' days')::INTERVAL
|
||||||
|
AND read_at IS NOT NULL
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
SELECT COUNT(*) INTO v_deleted FROM deleted;
|
||||||
|
|
||||||
|
RETURN v_deleted;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION notifications.cleanup_old_notifications IS 'Elimina notificaciones leidas mas antiguas que p_days_to_keep dias';
|
||||||
|
|
||||||
|
-- Funcion: Obtener dispositivos activos de un usuario
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.get_user_active_devices(
|
||||||
|
p_user_id UUID
|
||||||
|
) RETURNS TABLE (
|
||||||
|
id UUID,
|
||||||
|
device_type notifications.device_type,
|
||||||
|
device_token TEXT,
|
||||||
|
device_name VARCHAR,
|
||||||
|
browser VARCHAR,
|
||||||
|
os VARCHAR,
|
||||||
|
last_used_at TIMESTAMPTZ
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
d.id,
|
||||||
|
d.device_type,
|
||||||
|
d.device_token,
|
||||||
|
d.device_name,
|
||||||
|
d.browser,
|
||||||
|
d.os,
|
||||||
|
d.last_used_at
|
||||||
|
FROM notifications.user_devices d
|
||||||
|
WHERE d.user_id = p_user_id
|
||||||
|
AND d.is_active = TRUE
|
||||||
|
ORDER BY d.last_used_at DESC;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION notifications.get_user_active_devices IS 'Obtiene dispositivos activos de un usuario para push notifications';
|
||||||
|
|
||||||
|
-- Funcion: Estadisticas de cola
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.get_queue_stats()
|
||||||
|
RETURNS TABLE (
|
||||||
|
status notifications.queue_status,
|
||||||
|
channel notifications.channel,
|
||||||
|
count BIGINT,
|
||||||
|
oldest_created_at TIMESTAMPTZ
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
q.status,
|
||||||
|
q.channel,
|
||||||
|
COUNT(*)::BIGINT,
|
||||||
|
MIN(q.created_at)
|
||||||
|
FROM notifications.notification_queue q
|
||||||
|
GROUP BY q.status, q.channel
|
||||||
|
ORDER BY q.status, q.channel;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION notifications.get_queue_stats IS 'Estadisticas de la cola de notificaciones por status y canal';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- TRIGGERS
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Trigger para actualizar last_used_at en user_devices
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.update_device_last_used()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.last_used_at := NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_user_devices_last_used ON notifications.user_devices;
|
||||||
|
CREATE TRIGGER trg_user_devices_last_used
|
||||||
|
BEFORE UPDATE ON notifications.user_devices
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN (OLD.device_token IS DISTINCT FROM NEW.device_token)
|
||||||
|
EXECUTE FUNCTION notifications.update_device_last_used();
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- GRANTS (si se usa rol de aplicacion)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Grants para el rol de aplicacion (descomentar si se usa)
|
||||||
|
-- GRANT SELECT, INSERT, UPDATE, DELETE ON notifications.user_devices TO app_user;
|
||||||
|
-- GRANT SELECT, INSERT, UPDATE, DELETE ON notifications.notification_queue TO app_user;
|
||||||
|
-- GRANT SELECT, INSERT ON notifications.notification_logs TO app_user;
|
||||||
|
-- GRANT EXECUTE ON FUNCTION notifications.enqueue_notification TO app_user;
|
||||||
|
-- GRANT EXECUTE ON FUNCTION notifications.get_pending_queue_items TO app_user;
|
||||||
|
-- GRANT EXECUTE ON FUNCTION notifications.complete_queue_item TO app_user;
|
||||||
|
-- GRANT EXECUTE ON FUNCTION notifications.get_user_active_devices TO app_user;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- FIN
|
||||||
|
-- ============================================
|
||||||
120
ddl/schemas/plans/tables/01-plans.sql
Normal file
120
ddl/schemas/plans/tables/01-plans.sql
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Plans
|
||||||
|
-- Schema: plans
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE plans.plans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Basic info
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
slug VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
tagline VARCHAR(200), -- Short marketing text
|
||||||
|
|
||||||
|
-- Pricing
|
||||||
|
price_monthly DECIMAL(10, 2),
|
||||||
|
price_yearly DECIMAL(10, 2),
|
||||||
|
currency VARCHAR(3) DEFAULT 'USD',
|
||||||
|
|
||||||
|
-- Stripe integration
|
||||||
|
stripe_product_id VARCHAR(255),
|
||||||
|
stripe_price_id_monthly VARCHAR(255),
|
||||||
|
stripe_price_id_yearly VARCHAR(255),
|
||||||
|
|
||||||
|
-- Features (JSONB array)
|
||||||
|
features JSONB DEFAULT '[]'::jsonb,
|
||||||
|
-- Example:
|
||||||
|
-- [
|
||||||
|
-- { "name": "Users", "value": "5", "highlight": true },
|
||||||
|
-- { "name": "Storage", "value": "10GB" },
|
||||||
|
-- { "name": "API Access", "value": true }
|
||||||
|
-- ]
|
||||||
|
|
||||||
|
-- Limits (JSONB object)
|
||||||
|
limits JSONB DEFAULT '{}'::jsonb,
|
||||||
|
-- Example:
|
||||||
|
-- {
|
||||||
|
-- "max_users": 5,
|
||||||
|
-- "max_storage_gb": 10,
|
||||||
|
-- "max_api_calls_month": 10000,
|
||||||
|
-- "max_products": 100,
|
||||||
|
-- "max_projects": 10
|
||||||
|
-- }
|
||||||
|
|
||||||
|
-- Feature flags included
|
||||||
|
included_features JSONB DEFAULT '[]'::jsonb,
|
||||||
|
-- Example: ["advanced_analytics", "api_access", "priority_support"]
|
||||||
|
|
||||||
|
-- Display
|
||||||
|
is_popular BOOLEAN DEFAULT FALSE, -- Highlight in pricing page
|
||||||
|
is_enterprise BOOLEAN DEFAULT FALSE, -- Contact sales
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_visible BOOLEAN DEFAULT TRUE, -- Show in pricing page
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
|
||||||
|
-- Trial
|
||||||
|
trial_days INT DEFAULT 14,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT valid_plan_slug CHECK (slug ~ '^[a-z][a-z0-9-]*$'),
|
||||||
|
CONSTRAINT valid_prices CHECK (price_monthly >= 0 AND price_yearly >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Plan features detailed (optional, for complex feature matrix)
|
||||||
|
CREATE TABLE plans.plan_features (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
plan_id UUID NOT NULL REFERENCES plans.plans(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Feature definition
|
||||||
|
feature_code VARCHAR(100) NOT NULL, -- e.g., "api_access"
|
||||||
|
feature_name VARCHAR(200) NOT NULL,
|
||||||
|
category VARCHAR(100),
|
||||||
|
|
||||||
|
-- Value
|
||||||
|
value_type VARCHAR(20) NOT NULL, -- 'boolean', 'number', 'text'
|
||||||
|
value_boolean BOOLEAN,
|
||||||
|
value_number INT,
|
||||||
|
value_text VARCHAR(200),
|
||||||
|
|
||||||
|
-- Display
|
||||||
|
display_value VARCHAR(100), -- "Unlimited", "10GB", "Yes"
|
||||||
|
is_highlighted BOOLEAN DEFAULT FALSE,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT unique_plan_feature UNIQUE (plan_id, feature_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_plans_slug ON plans.plans(slug) WHERE is_active = TRUE;
|
||||||
|
CREATE INDEX idx_plans_active ON plans.plans(is_active, is_visible, sort_order);
|
||||||
|
CREATE INDEX idx_plan_features_plan ON plans.plan_features(plan_id);
|
||||||
|
|
||||||
|
-- Trigger
|
||||||
|
CREATE OR REPLACE FUNCTION plans.update_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_plans_updated_at
|
||||||
|
BEFORE UPDATE ON plans.plans
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION plans.update_updated_at();
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE plans.plans IS 'Subscription plans definition';
|
||||||
|
COMMENT ON TABLE plans.plan_features IS 'Detailed feature matrix per plan';
|
||||||
|
COMMENT ON COLUMN plans.plans.limits IS 'Quantitative limits enforced by the system';
|
||||||
|
COMMENT ON COLUMN plans.plans.included_features IS 'Feature flag codes included in this plan';
|
||||||
126
ddl/schemas/storage/tables/01-files.sql
Normal file
126
ddl/schemas/storage/tables/01-files.sql
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- Storage Files Table
|
||||||
|
-- Main table for tracking uploaded files
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE storage.files (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
uploaded_by UUID NOT NULL REFERENCES users.users(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- File info
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
original_name VARCHAR(500) NOT NULL,
|
||||||
|
mime_type VARCHAR(100) NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL CHECK (size_bytes > 0),
|
||||||
|
|
||||||
|
-- Storage location
|
||||||
|
bucket VARCHAR(100) NOT NULL,
|
||||||
|
path VARCHAR(1000) NOT NULL,
|
||||||
|
provider storage.storage_provider NOT NULL DEFAULT 's3',
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status storage.file_status NOT NULL DEFAULT 'ready',
|
||||||
|
visibility storage.visibility NOT NULL DEFAULT 'private',
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
thumbnails JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Folder organization
|
||||||
|
folder VARCHAR(100) DEFAULT 'files',
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Unique constraint on path within bucket
|
||||||
|
CONSTRAINT uq_storage_files_path UNIQUE (bucket, path)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_storage_files_tenant ON storage.files(tenant_id);
|
||||||
|
CREATE INDEX idx_storage_files_tenant_folder ON storage.files(tenant_id, folder);
|
||||||
|
CREATE INDEX idx_storage_files_uploaded_by ON storage.files(uploaded_by);
|
||||||
|
CREATE INDEX idx_storage_files_mime_type ON storage.files(mime_type);
|
||||||
|
CREATE INDEX idx_storage_files_status ON storage.files(status);
|
||||||
|
CREATE INDEX idx_storage_files_created_at ON storage.files(created_at DESC);
|
||||||
|
CREATE INDEX idx_storage_files_deleted ON storage.files(tenant_id, deleted_at) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE storage.files ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY storage_files_tenant_isolation ON storage.files
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE storage.files IS 'Uploaded files metadata and storage location';
|
||||||
|
COMMENT ON COLUMN storage.files.path IS 'Full path in bucket: tenant_id/folder/upload_id/filename';
|
||||||
|
COMMENT ON COLUMN storage.files.thumbnails IS 'Generated thumbnail paths as JSON {thumb: path, medium: path}';
|
||||||
|
COMMENT ON COLUMN storage.files.folder IS 'Logical folder: avatars, documents, imports, etc.';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Pending Uploads Table
|
||||||
|
-- Tracks upload requests before confirmation
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE storage.pending_uploads (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Upload request info
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
original_name VARCHAR(500) NOT NULL,
|
||||||
|
mime_type VARCHAR(100) NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
folder VARCHAR(100) DEFAULT 'files',
|
||||||
|
|
||||||
|
-- Presigned URL info
|
||||||
|
bucket VARCHAR(100) NOT NULL,
|
||||||
|
path VARCHAR(1000) NOT NULL,
|
||||||
|
provider storage.storage_provider NOT NULL DEFAULT 's3',
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'completed', 'expired', 'failed')),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_storage_pending_tenant ON storage.pending_uploads(tenant_id);
|
||||||
|
CREATE INDEX idx_storage_pending_user ON storage.pending_uploads(user_id);
|
||||||
|
CREATE INDEX idx_storage_pending_status ON storage.pending_uploads(status);
|
||||||
|
CREATE INDEX idx_storage_pending_expires ON storage.pending_uploads(expires_at) WHERE status = 'pending';
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE storage.pending_uploads ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY storage_pending_tenant_isolation ON storage.pending_uploads
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Auto-cleanup expired uploads (can be called by cron)
|
||||||
|
CREATE OR REPLACE FUNCTION storage.cleanup_expired_uploads()
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
deleted_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
UPDATE storage.pending_uploads
|
||||||
|
SET status = 'expired'
|
||||||
|
WHERE status = 'pending'
|
||||||
|
AND expires_at < NOW();
|
||||||
|
|
||||||
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||||
|
RETURN deleted_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
COMMENT ON TABLE storage.pending_uploads IS 'Pending file uploads awaiting confirmation';
|
||||||
|
COMMENT ON FUNCTION storage.cleanup_expired_uploads IS 'Mark expired pending uploads';
|
||||||
178
ddl/schemas/storage/tables/02-storage-usage.sql
Normal file
178
ddl/schemas/storage/tables/02-storage-usage.sql
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- Storage Usage Table
|
||||||
|
-- Tracks storage usage per tenant
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE storage.usage (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Usage metrics
|
||||||
|
total_files INTEGER NOT NULL DEFAULT 0 CHECK (total_files >= 0),
|
||||||
|
total_bytes BIGINT NOT NULL DEFAULT 0 CHECK (total_bytes >= 0),
|
||||||
|
|
||||||
|
-- By category
|
||||||
|
files_by_type JSONB DEFAULT '{}',
|
||||||
|
bytes_by_type JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Limits from plan
|
||||||
|
max_bytes BIGINT,
|
||||||
|
max_file_size BIGINT,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- One record per tenant
|
||||||
|
CONSTRAINT uq_storage_usage_tenant UNIQUE (tenant_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_storage_usage_tenant ON storage.usage(tenant_id);
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE storage.usage ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY storage_usage_tenant_isolation ON storage.usage
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE storage.usage IS 'Aggregated storage usage per tenant';
|
||||||
|
COMMENT ON COLUMN storage.usage.files_by_type IS 'File count by mime type category: {images: 10, documents: 5}';
|
||||||
|
COMMENT ON COLUMN storage.usage.bytes_by_type IS 'Bytes by mime type category: {images: 1024000, documents: 512000}';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Function: Update storage usage on file changes
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION storage.update_usage_on_file_change()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' AND NEW.status = 'ready' THEN
|
||||||
|
-- New file added
|
||||||
|
INSERT INTO storage.usage (tenant_id, total_files, total_bytes)
|
||||||
|
VALUES (NEW.tenant_id, 1, NEW.size_bytes)
|
||||||
|
ON CONFLICT (tenant_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
total_files = storage.usage.total_files + 1,
|
||||||
|
total_bytes = storage.usage.total_bytes + NEW.size_bytes,
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
ELSIF TG_OP = 'UPDATE' AND OLD.status = 'ready' AND NEW.status != 'ready' THEN
|
||||||
|
-- File deleted/moved
|
||||||
|
UPDATE storage.usage
|
||||||
|
SET
|
||||||
|
total_files = GREATEST(0, total_files - 1),
|
||||||
|
total_bytes = GREATEST(0, total_bytes - OLD.size_bytes),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE tenant_id = OLD.tenant_id;
|
||||||
|
|
||||||
|
ELSIF TG_OP = 'DELETE' AND OLD.status = 'ready' THEN
|
||||||
|
-- Hard delete
|
||||||
|
UPDATE storage.usage
|
||||||
|
SET
|
||||||
|
total_files = GREATEST(0, total_files - 1),
|
||||||
|
total_bytes = GREATEST(0, total_bytes - OLD.size_bytes),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE tenant_id = OLD.tenant_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN COALESCE(NEW, OLD);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER update_storage_usage
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON storage.files
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION storage.update_usage_on_file_change();
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION storage.update_usage_on_file_change IS 'Automatically update tenant storage usage';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Function: Check if tenant can upload
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION storage.can_upload(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_size_bytes BIGINT
|
||||||
|
) RETURNS BOOLEAN AS $$
|
||||||
|
DECLARE
|
||||||
|
v_current_bytes BIGINT;
|
||||||
|
v_max_bytes BIGINT;
|
||||||
|
v_max_file_size BIGINT;
|
||||||
|
BEGIN
|
||||||
|
SELECT total_bytes, max_bytes, max_file_size
|
||||||
|
INTO v_current_bytes, v_max_bytes, v_max_file_size
|
||||||
|
FROM storage.usage
|
||||||
|
WHERE tenant_id = p_tenant_id;
|
||||||
|
|
||||||
|
-- No usage record means no limit check needed yet
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN TRUE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Check max file size
|
||||||
|
IF v_max_file_size IS NOT NULL AND p_size_bytes > v_max_file_size THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Check total storage limit
|
||||||
|
IF v_max_bytes IS NOT NULL AND (v_current_bytes + p_size_bytes) > v_max_bytes THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN TRUE;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION storage.can_upload IS 'Check if tenant can upload file of given size';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Function: Get tenant storage stats
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION storage.get_tenant_stats(p_tenant_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
total_files INTEGER,
|
||||||
|
total_bytes BIGINT,
|
||||||
|
max_bytes BIGINT,
|
||||||
|
max_file_size BIGINT,
|
||||||
|
usage_percent NUMERIC,
|
||||||
|
files_by_folder JSONB
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
COALESCE(u.total_files, 0)::INTEGER,
|
||||||
|
COALESCE(u.total_bytes, 0)::BIGINT,
|
||||||
|
u.max_bytes,
|
||||||
|
u.max_file_size,
|
||||||
|
CASE
|
||||||
|
WHEN u.max_bytes IS NULL OR u.max_bytes = 0 THEN 0
|
||||||
|
ELSE ROUND((u.total_bytes::NUMERIC / u.max_bytes * 100), 2)
|
||||||
|
END,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT jsonb_object_agg(folder, count)
|
||||||
|
FROM (
|
||||||
|
SELECT folder, COUNT(*) as count
|
||||||
|
FROM storage.files
|
||||||
|
WHERE tenant_id = p_tenant_id AND deleted_at IS NULL
|
||||||
|
GROUP BY folder
|
||||||
|
) t),
|
||||||
|
'{}'::jsonb
|
||||||
|
)
|
||||||
|
FROM storage.usage u
|
||||||
|
WHERE u.tenant_id = p_tenant_id;
|
||||||
|
|
||||||
|
-- If no record, return defaults
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN QUERY SELECT 0, 0::BIGINT, NULL::BIGINT, NULL::BIGINT, 0::NUMERIC, '{}'::JSONB;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION storage.get_tenant_stats IS 'Get storage statistics for tenant';
|
||||||
79
ddl/schemas/tenants/tables/01-tenants.sql
Normal file
79
ddl/schemas/tenants/tables/01-tenants.sql
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Tenants Table
|
||||||
|
-- Schema: tenants
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE tenants.tenants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Basic info
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
domain VARCHAR(255) UNIQUE,
|
||||||
|
logo_url VARCHAR(500),
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status tenants.tenant_status DEFAULT 'pending' NOT NULL,
|
||||||
|
|
||||||
|
-- Subscription
|
||||||
|
plan_id UUID, -- FK to plans.plans
|
||||||
|
subscription_status tenants.subscription_status DEFAULT 'trialing',
|
||||||
|
trial_ends_at TIMESTAMPTZ,
|
||||||
|
subscription_ends_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Stripe integration
|
||||||
|
stripe_customer_id VARCHAR(255) UNIQUE,
|
||||||
|
stripe_subscription_id VARCHAR(255) UNIQUE,
|
||||||
|
|
||||||
|
-- Settings (JSONB for flexibility)
|
||||||
|
settings JSONB DEFAULT '{}'::jsonb,
|
||||||
|
-- Example settings:
|
||||||
|
-- {
|
||||||
|
-- "timezone": "America/Mexico_City",
|
||||||
|
-- "locale": "es-MX",
|
||||||
|
-- "currency": "MXN",
|
||||||
|
-- "date_format": "DD/MM/YYYY",
|
||||||
|
-- "features": {}
|
||||||
|
-- }
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
|
-- Audit fields
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
created_by UUID,
|
||||||
|
updated_by UUID,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT valid_slug CHECK (slug ~ '^[a-z0-9]([a-z0-9-]*[a-z0-9])?$'),
|
||||||
|
CONSTRAINT slug_length CHECK (LENGTH(slug) >= 3 AND LENGTH(slug) <= 50)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_tenants_status ON tenants.tenants(status) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_tenants_slug ON tenants.tenants(slug) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_tenants_stripe_customer ON tenants.tenants(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_tenants_plan ON tenants.tenants(plan_id) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- Trigger for updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION tenants.update_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_tenants_updated_at
|
||||||
|
BEFORE UPDATE ON tenants.tenants
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION tenants.update_updated_at();
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE tenants.tenants IS 'Core tenant table for multi-tenancy';
|
||||||
|
COMMENT ON COLUMN tenants.tenants.slug IS 'URL-safe identifier, used in subdomains';
|
||||||
|
COMMENT ON COLUMN tenants.tenants.settings IS 'Tenant-specific configuration (JSON)';
|
||||||
|
COMMENT ON COLUMN tenants.tenants.stripe_customer_id IS 'Stripe Customer ID for billing';
|
||||||
53
ddl/schemas/tenants/tables/02-tenant-settings.sql
Normal file
53
ddl/schemas/tenants/tables/02-tenant-settings.sql
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Tenant Settings Table
|
||||||
|
-- Schema: tenants
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- For structured settings that need to be queried
|
||||||
|
CREATE TABLE tenants.tenant_settings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Setting identification
|
||||||
|
category VARCHAR(100) NOT NULL, -- 'general', 'billing', 'notifications', 'security'
|
||||||
|
key VARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
|
-- Value (supports different types)
|
||||||
|
value_string VARCHAR(1000),
|
||||||
|
value_number DECIMAL(20, 4),
|
||||||
|
value_boolean BOOLEAN,
|
||||||
|
value_json JSONB,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
description TEXT,
|
||||||
|
is_sensitive BOOLEAN DEFAULT FALSE, -- For encryption at rest
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
updated_by UUID,
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT unique_tenant_setting UNIQUE (tenant_id, category, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_tenant_settings_tenant ON tenants.tenant_settings(tenant_id);
|
||||||
|
CREATE INDEX idx_tenant_settings_category ON tenants.tenant_settings(tenant_id, category);
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE tenants.tenant_settings ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_settings_isolation ON tenants.tenant_settings
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Trigger for updated_at
|
||||||
|
CREATE TRIGGER trg_tenant_settings_updated_at
|
||||||
|
BEFORE UPDATE ON tenants.tenant_settings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION tenants.update_updated_at();
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE tenants.tenant_settings IS 'Structured tenant settings for queryable configuration';
|
||||||
|
COMMENT ON COLUMN tenants.tenant_settings.is_sensitive IS 'Flag for values that should be encrypted at rest';
|
||||||
114
ddl/schemas/users/tables/01-users.sql
Normal file
114
ddl/schemas/users/tables/01-users.sql
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Users Table
|
||||||
|
-- Schema: users
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE users.users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Authentication
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
password_hash VARCHAR(255), -- NULL for OAuth-only users
|
||||||
|
email_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
email_verified_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Profile
|
||||||
|
first_name VARCHAR(100),
|
||||||
|
last_name VARCHAR(100),
|
||||||
|
display_name VARCHAR(200),
|
||||||
|
avatar_url VARCHAR(500),
|
||||||
|
phone VARCHAR(50),
|
||||||
|
phone_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status users.user_status DEFAULT 'pending' NOT NULL,
|
||||||
|
is_owner BOOLEAN DEFAULT FALSE, -- Tenant owner
|
||||||
|
|
||||||
|
-- Security
|
||||||
|
mfa_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
mfa_secret VARCHAR(255),
|
||||||
|
mfa_backup_codes TEXT[], -- Hashed backup codes for MFA recovery
|
||||||
|
mfa_enabled_at TIMESTAMPTZ, -- When MFA was enabled
|
||||||
|
password_changed_at TIMESTAMPTZ,
|
||||||
|
failed_login_attempts INT DEFAULT 0,
|
||||||
|
locked_until TIMESTAMPTZ,
|
||||||
|
last_login_ip VARCHAR(45), -- IPv4 or IPv6 address
|
||||||
|
|
||||||
|
-- Preferences (JSONB)
|
||||||
|
preferences JSONB DEFAULT '{}'::jsonb,
|
||||||
|
-- Example:
|
||||||
|
-- {
|
||||||
|
-- "theme": "dark",
|
||||||
|
-- "language": "es",
|
||||||
|
-- "notifications": { "email": true, "push": true }
|
||||||
|
-- }
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
|
-- Activity
|
||||||
|
last_login_at TIMESTAMPTZ,
|
||||||
|
last_activity_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
created_by UUID,
|
||||||
|
updated_by UUID,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT unique_email_per_tenant UNIQUE (tenant_id, email),
|
||||||
|
CONSTRAINT valid_email CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_users_tenant ON users.users(tenant_id) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_users_email ON users.users(tenant_id, email) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_users_status ON users.users(tenant_id, status) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_users_owner ON users.users(tenant_id) WHERE is_owner = TRUE AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE users.users ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY users_tenant_isolation_select ON users.users
|
||||||
|
FOR SELECT
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
CREATE POLICY users_tenant_isolation_insert ON users.users
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
CREATE POLICY users_tenant_isolation_update ON users.users
|
||||||
|
FOR UPDATE
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID)
|
||||||
|
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
CREATE POLICY users_tenant_isolation_delete ON users.users
|
||||||
|
FOR DELETE
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Trigger for updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION users.update_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_users_updated_at
|
||||||
|
BEFORE UPDATE ON users.users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION users.update_updated_at();
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE users.users IS 'User accounts within tenants';
|
||||||
|
COMMENT ON COLUMN users.users.is_owner IS 'Tenant owner/admin flag';
|
||||||
|
COMMENT ON COLUMN users.users.password_hash IS 'bcrypt hashed password, NULL for OAuth users';
|
||||||
|
COMMENT ON COLUMN users.users.mfa_secret IS 'TOTP secret for 2FA (encrypted)';
|
||||||
|
COMMENT ON COLUMN users.users.mfa_backup_codes IS 'Array of hashed backup codes for MFA recovery';
|
||||||
|
COMMENT ON COLUMN users.users.mfa_enabled_at IS 'Timestamp when MFA was enabled';
|
||||||
|
COMMENT ON COLUMN users.users.last_login_ip IS 'IP address of last successful login';
|
||||||
102
ddl/schemas/users/tables/02-roles.sql
Normal file
102
ddl/schemas/users/tables/02-roles.sql
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Roles & Permissions
|
||||||
|
-- Schema: users
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Roles table
|
||||||
|
CREATE TABLE users.roles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
slug VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- System role (cannot be deleted)
|
||||||
|
is_system BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Permissions (JSONB array)
|
||||||
|
permissions JSONB DEFAULT '[]'::jsonb,
|
||||||
|
-- Example: ["users:read", "users:write", "billing:read"]
|
||||||
|
|
||||||
|
-- Hierarchy
|
||||||
|
parent_role_id UUID REFERENCES users.roles(id),
|
||||||
|
level INT DEFAULT 0, -- 0 = lowest, higher = more permissions
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
created_by UUID,
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT unique_role_slug_per_tenant UNIQUE (tenant_id, slug),
|
||||||
|
CONSTRAINT valid_role_slug CHECK (slug ~ '^[a-z][a-z0-9_]*$')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User-Role junction
|
||||||
|
CREATE TABLE users.user_roles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
|
||||||
|
role_id UUID NOT NULL REFERENCES users.roles(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Optional expiration
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
created_by UUID,
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT unique_user_role UNIQUE (user_id, role_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Permissions reference table (optional, for UI)
|
||||||
|
CREATE TABLE users.permissions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Permission definition
|
||||||
|
code VARCHAR(100) UNIQUE NOT NULL, -- e.g., "users:write"
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category VARCHAR(100), -- e.g., "users", "billing", "settings"
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
is_sensitive BOOLEAN DEFAULT FALSE, -- Requires extra confirmation
|
||||||
|
requires_owner BOOLEAN DEFAULT FALSE, -- Only tenant owner
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_roles_tenant ON users.roles(tenant_id);
|
||||||
|
CREATE INDEX idx_roles_slug ON users.roles(tenant_id, slug);
|
||||||
|
CREATE INDEX idx_user_roles_user ON users.user_roles(user_id);
|
||||||
|
CREATE INDEX idx_user_roles_role ON users.user_roles(role_id);
|
||||||
|
CREATE INDEX idx_user_roles_tenant ON users.user_roles(tenant_id);
|
||||||
|
|
||||||
|
-- RLS for roles
|
||||||
|
ALTER TABLE users.roles ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY roles_tenant_isolation ON users.roles
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- RLS for user_roles
|
||||||
|
ALTER TABLE users.user_roles ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY user_roles_tenant_isolation ON users.user_roles
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Triggers
|
||||||
|
CREATE TRIGGER trg_roles_updated_at
|
||||||
|
BEFORE UPDATE ON users.roles
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION users.update_updated_at();
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE users.roles IS 'Role definitions per tenant';
|
||||||
|
COMMENT ON TABLE users.user_roles IS 'User-Role assignments';
|
||||||
|
COMMENT ON TABLE users.permissions IS 'Permission registry (global reference)';
|
||||||
|
COMMENT ON COLUMN users.roles.permissions IS 'Array of permission codes';
|
||||||
|
COMMENT ON COLUMN users.roles.is_system IS 'System roles cannot be modified or deleted';
|
||||||
65
ddl/schemas/users/tables/03-invitations.sql
Normal file
65
ddl/schemas/users/tables/03-invitations.sql
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: User Invitations
|
||||||
|
-- Schema: users
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE users.invitations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Invitation details
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
token VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
|
||||||
|
-- Role assignment
|
||||||
|
role_id UUID REFERENCES users.roles(id),
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status users.invitation_status DEFAULT 'pending' NOT NULL,
|
||||||
|
|
||||||
|
-- Expiration
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
|
||||||
|
-- Result
|
||||||
|
accepted_at TIMESTAMPTZ,
|
||||||
|
accepted_by_user_id UUID REFERENCES users.users(id),
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
message TEXT, -- Custom message from inviter
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
created_by UUID NOT NULL REFERENCES users.users(id),
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT valid_invitation_email CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_invitations_tenant ON users.invitations(tenant_id);
|
||||||
|
CREATE INDEX idx_invitations_email ON users.invitations(tenant_id, email);
|
||||||
|
CREATE INDEX idx_invitations_token ON users.invitations(token);
|
||||||
|
CREATE INDEX idx_invitations_status ON users.invitations(tenant_id, status) WHERE status = 'pending';
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE users.invitations ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY invitations_tenant_isolation ON users.invitations
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Function to auto-expire invitations
|
||||||
|
CREATE OR REPLACE FUNCTION users.expire_old_invitations()
|
||||||
|
RETURNS void AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE users.invitations
|
||||||
|
SET status = 'expired'
|
||||||
|
WHERE status = 'pending'
|
||||||
|
AND expires_at < NOW();
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE users.invitations IS 'User invitations to join tenant';
|
||||||
|
COMMENT ON COLUMN users.invitations.token IS 'Secure token for invitation link';
|
||||||
50
ddl/schemas/webhooks/tables/01-webhooks.sql
Normal file
50
ddl/schemas/webhooks/tables/01-webhooks.sql
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- WEBHOOKS: Webhook Configuration Table
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Main webhooks table
|
||||||
|
CREATE TABLE webhooks.webhooks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Webhook config
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
secret TEXT NOT NULL, -- HMAC signing secret (encrypted in app layer)
|
||||||
|
|
||||||
|
-- Events subscribed (stored as text array for flexibility)
|
||||||
|
events TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Custom headers to send
|
||||||
|
headers JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_by UUID REFERENCES users.users(id),
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT webhooks_url_valid CHECK (url ~ '^https://'),
|
||||||
|
CONSTRAINT webhooks_events_not_empty CHECK (array_length(events, 1) > 0 OR events = '{}')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_webhooks_tenant ON webhooks.webhooks(tenant_id);
|
||||||
|
CREATE INDEX idx_webhooks_active ON webhooks.webhooks(tenant_id, is_active) WHERE is_active = true;
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE webhooks.webhooks ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY webhooks_tenant_isolation ON webhooks.webhooks
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Comment
|
||||||
|
COMMENT ON TABLE webhooks.webhooks IS 'Webhook configurations per tenant';
|
||||||
|
COMMENT ON COLUMN webhooks.webhooks.secret IS 'HMAC-SHA256 signing secret for payload verification';
|
||||||
|
COMMENT ON COLUMN webhooks.webhooks.events IS 'Array of event types this webhook subscribes to';
|
||||||
|
COMMENT ON COLUMN webhooks.webhooks.headers IS 'Custom HTTP headers to include in webhook requests';
|
||||||
117
ddl/schemas/webhooks/tables/02-deliveries.sql
Normal file
117
ddl/schemas/webhooks/tables/02-deliveries.sql
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- WEBHOOKS: Delivery Log Table
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Delivery attempts table
|
||||||
|
CREATE TABLE webhooks.deliveries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
webhook_id UUID NOT NULL REFERENCES webhooks.webhooks(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Event info
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
|
||||||
|
-- Delivery status
|
||||||
|
status webhooks.delivery_status NOT NULL DEFAULT 'pending',
|
||||||
|
|
||||||
|
-- Response info
|
||||||
|
response_status INTEGER,
|
||||||
|
response_body TEXT,
|
||||||
|
response_headers JSONB,
|
||||||
|
|
||||||
|
-- Retry tracking
|
||||||
|
attempt INTEGER NOT NULL DEFAULT 1,
|
||||||
|
max_attempts INTEGER NOT NULL DEFAULT 5,
|
||||||
|
next_retry_at TIMESTAMPTZ,
|
||||||
|
last_error TEXT,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
delivered_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_deliveries_webhook ON webhooks.deliveries(webhook_id);
|
||||||
|
CREATE INDEX idx_deliveries_tenant ON webhooks.deliveries(tenant_id);
|
||||||
|
CREATE INDEX idx_deliveries_status ON webhooks.deliveries(status) WHERE status IN ('pending', 'retrying');
|
||||||
|
CREATE INDEX idx_deliveries_retry ON webhooks.deliveries(next_retry_at) WHERE status = 'retrying' AND next_retry_at IS NOT NULL;
|
||||||
|
CREATE INDEX idx_deliveries_created ON webhooks.deliveries(created_at DESC);
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE webhooks.deliveries ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY deliveries_tenant_isolation ON webhooks.deliveries
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Function to calculate next retry time with exponential backoff
|
||||||
|
CREATE OR REPLACE FUNCTION webhooks.calculate_next_retry(attempt INTEGER)
|
||||||
|
RETURNS TIMESTAMPTZ AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Retry schedule: 1min, 5min, 30min, 2hours, 6hours
|
||||||
|
RETURN CASE attempt
|
||||||
|
WHEN 1 THEN NOW() + INTERVAL '1 minute'
|
||||||
|
WHEN 2 THEN NOW() + INTERVAL '5 minutes'
|
||||||
|
WHEN 3 THEN NOW() + INTERVAL '30 minutes'
|
||||||
|
WHEN 4 THEN NOW() + INTERVAL '2 hours'
|
||||||
|
WHEN 5 THEN NOW() + INTERVAL '6 hours'
|
||||||
|
ELSE NULL -- No more retries
|
||||||
|
END;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||||
|
|
||||||
|
-- Function to get pending deliveries for retry
|
||||||
|
CREATE OR REPLACE FUNCTION webhooks.get_pending_retries(batch_size INTEGER DEFAULT 100)
|
||||||
|
RETURNS SETOF webhooks.deliveries AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT *
|
||||||
|
FROM webhooks.deliveries
|
||||||
|
WHERE status = 'retrying'
|
||||||
|
AND next_retry_at <= NOW()
|
||||||
|
ORDER BY next_retry_at ASC
|
||||||
|
LIMIT batch_size
|
||||||
|
FOR UPDATE SKIP LOCKED;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Function to get delivery stats for a webhook
|
||||||
|
CREATE OR REPLACE FUNCTION webhooks.get_webhook_stats(p_webhook_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
total_deliveries BIGINT,
|
||||||
|
successful_deliveries BIGINT,
|
||||||
|
failed_deliveries BIGINT,
|
||||||
|
pending_deliveries BIGINT,
|
||||||
|
success_rate NUMERIC,
|
||||||
|
last_delivery_at TIMESTAMPTZ
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::BIGINT as total_deliveries,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'delivered')::BIGINT as successful_deliveries,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'failed')::BIGINT as failed_deliveries,
|
||||||
|
COUNT(*) FILTER (WHERE status IN ('pending', 'retrying'))::BIGINT as pending_deliveries,
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(*) FILTER (WHERE status IN ('delivered', 'failed')) > 0
|
||||||
|
THEN ROUND(
|
||||||
|
COUNT(*) FILTER (WHERE status = 'delivered')::NUMERIC /
|
||||||
|
COUNT(*) FILTER (WHERE status IN ('delivered', 'failed'))::NUMERIC * 100, 2
|
||||||
|
)
|
||||||
|
ELSE 0
|
||||||
|
END as success_rate,
|
||||||
|
MAX(delivered_at) as last_delivery_at
|
||||||
|
FROM webhooks.deliveries
|
||||||
|
WHERE webhook_id = p_webhook_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE webhooks.deliveries IS 'Webhook delivery attempts and status tracking';
|
||||||
|
COMMENT ON COLUMN webhooks.deliveries.attempt IS 'Current attempt number (1-based)';
|
||||||
|
COMMENT ON COLUMN webhooks.deliveries.next_retry_at IS 'When to retry if status is retrying';
|
||||||
|
COMMENT ON FUNCTION webhooks.calculate_next_retry IS 'Calculate next retry time with exponential backoff';
|
||||||
|
COMMENT ON FUNCTION webhooks.get_pending_retries IS 'Get deliveries ready for retry processing';
|
||||||
|
COMMENT ON FUNCTION webhooks.get_webhook_stats IS 'Get delivery statistics for a webhook';
|
||||||
18
ddl/schemas/whatsapp/00-schema.sql
Normal file
18
ddl/schemas/whatsapp/00-schema.sql
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: WhatsApp Schema
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Create schema
|
||||||
|
CREATE SCHEMA IF NOT EXISTS whatsapp;
|
||||||
|
|
||||||
|
-- Grant permissions
|
||||||
|
GRANT USAGE ON SCHEMA whatsapp TO template_saas_app;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA whatsapp TO template_saas_app;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA whatsapp TO template_saas_app;
|
||||||
|
|
||||||
|
-- Default privileges for future tables
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA whatsapp
|
||||||
|
GRANT ALL PRIVILEGES ON TABLES TO template_saas_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA whatsapp
|
||||||
|
GRANT ALL PRIVILEGES ON SEQUENCES TO template_saas_app;
|
||||||
60
ddl/schemas/whatsapp/tables/01-whatsapp-configs.sql
Normal file
60
ddl/schemas/whatsapp/tables/01-whatsapp-configs.sql
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- WhatsApp Configuration per Tenant
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE whatsapp.configs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Meta Cloud API credentials
|
||||||
|
phone_number_id VARCHAR(50) NOT NULL,
|
||||||
|
business_account_id VARCHAR(50) NOT NULL,
|
||||||
|
access_token TEXT NOT NULL, -- Encrypted
|
||||||
|
|
||||||
|
-- Webhook configuration
|
||||||
|
webhook_verify_token VARCHAR(100),
|
||||||
|
webhook_secret VARCHAR(255),
|
||||||
|
|
||||||
|
-- Configuration
|
||||||
|
display_phone_number VARCHAR(20),
|
||||||
|
verified_name VARCHAR(255),
|
||||||
|
quality_rating VARCHAR(50),
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
is_verified BOOLEAN DEFAULT false,
|
||||||
|
last_verified_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Rate limiting
|
||||||
|
daily_message_limit INTEGER DEFAULT 1000,
|
||||||
|
messages_sent_today INTEGER DEFAULT 0,
|
||||||
|
last_message_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
|
||||||
|
UNIQUE(tenant_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_whatsapp_configs_tenant ON whatsapp.configs(tenant_id);
|
||||||
|
CREATE INDEX idx_whatsapp_configs_phone ON whatsapp.configs(phone_number_id);
|
||||||
|
CREATE INDEX idx_whatsapp_configs_active ON whatsapp.configs(is_active) WHERE is_active = true;
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE whatsapp.configs ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY whatsapp_configs_tenant_isolation ON whatsapp.configs
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
||||||
|
|
||||||
|
-- Trigger for updated_at
|
||||||
|
CREATE TRIGGER update_whatsapp_configs_updated_at
|
||||||
|
BEFORE UPDATE ON whatsapp.configs
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE whatsapp.configs IS 'WhatsApp Business API configuration per tenant';
|
||||||
|
COMMENT ON COLUMN whatsapp.configs.access_token IS 'Encrypted Meta Cloud API access token';
|
||||||
|
COMMENT ON COLUMN whatsapp.configs.quality_rating IS 'GREEN, YELLOW, or RED quality rating from Meta';
|
||||||
72
ddl/schemas/whatsapp/tables/02-whatsapp-messages.sql
Normal file
72
ddl/schemas/whatsapp/tables/02-whatsapp-messages.sql
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- WhatsApp Message Log
|
||||||
|
-- ============================================
|
||||||
|
-- Note: ENUMs are defined in 02-enums.sql
|
||||||
|
|
||||||
|
CREATE TABLE whatsapp.messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
config_id UUID NOT NULL REFERENCES whatsapp.configs(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Message identifiers
|
||||||
|
wamid VARCHAR(100), -- WhatsApp Message ID from Meta
|
||||||
|
conversation_id VARCHAR(100),
|
||||||
|
|
||||||
|
-- Direction and type
|
||||||
|
direction whatsapp.message_direction NOT NULL DEFAULT 'outbound',
|
||||||
|
message_type whatsapp.message_type NOT NULL DEFAULT 'text',
|
||||||
|
|
||||||
|
-- Recipient/Sender
|
||||||
|
phone_number VARCHAR(20) NOT NULL, -- E.164 format
|
||||||
|
user_id UUID REFERENCES users.users(id) ON DELETE SET NULL,
|
||||||
|
contact_name VARCHAR(255),
|
||||||
|
|
||||||
|
-- Content
|
||||||
|
content TEXT,
|
||||||
|
template_name VARCHAR(100),
|
||||||
|
template_language VARCHAR(10) DEFAULT 'es',
|
||||||
|
template_components JSONB,
|
||||||
|
media_url TEXT,
|
||||||
|
media_mime_type VARCHAR(100),
|
||||||
|
|
||||||
|
-- Status tracking
|
||||||
|
status whatsapp.message_status DEFAULT 'pending',
|
||||||
|
status_timestamp TIMESTAMPTZ,
|
||||||
|
error_code VARCHAR(50),
|
||||||
|
error_message TEXT,
|
||||||
|
|
||||||
|
-- Pricing
|
||||||
|
pricing_model VARCHAR(50),
|
||||||
|
pricing_category VARCHAR(50),
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_whatsapp_messages_tenant ON whatsapp.messages(tenant_id);
|
||||||
|
CREATE INDEX idx_whatsapp_messages_config ON whatsapp.messages(config_id);
|
||||||
|
CREATE INDEX idx_whatsapp_messages_wamid ON whatsapp.messages(wamid);
|
||||||
|
CREATE INDEX idx_whatsapp_messages_phone ON whatsapp.messages(phone_number);
|
||||||
|
CREATE INDEX idx_whatsapp_messages_user ON whatsapp.messages(user_id);
|
||||||
|
CREATE INDEX idx_whatsapp_messages_status ON whatsapp.messages(status);
|
||||||
|
CREATE INDEX idx_whatsapp_messages_created ON whatsapp.messages(created_at DESC);
|
||||||
|
CREATE INDEX idx_whatsapp_messages_direction ON whatsapp.messages(direction);
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE whatsapp.messages ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY whatsapp_messages_tenant_isolation ON whatsapp.messages
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
||||||
|
|
||||||
|
-- Trigger for updated_at
|
||||||
|
CREATE TRIGGER update_whatsapp_messages_updated_at
|
||||||
|
BEFORE UPDATE ON whatsapp.messages
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE whatsapp.messages IS 'WhatsApp message history for both inbound and outbound messages';
|
||||||
|
COMMENT ON COLUMN whatsapp.messages.wamid IS 'WhatsApp Message ID returned by Meta API';
|
||||||
|
COMMENT ON COLUMN whatsapp.messages.phone_number IS 'Phone number in E.164 format (e.g., +521234567890)';
|
||||||
251
scripts/create-database.sh
Executable file
251
scripts/create-database.sh
Executable file
@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================
|
||||||
|
# TEMPLATE-SAAS: Create Database Script
|
||||||
|
# Version: 1.0.0
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Default values
|
||||||
|
DB_HOST="${DB_HOST:-localhost}"
|
||||||
|
DB_PORT="${DB_PORT:-5432}"
|
||||||
|
DB_NAME="${DB_NAME:-template_saas_dev}"
|
||||||
|
DB_USER="${DB_USER:-template_saas_user}"
|
||||||
|
DB_PASSWORD="${DB_PASSWORD:-template_saas_dev_2026}"
|
||||||
|
DB_ADMIN_USER="${DB_ADMIN_USER:-postgres}"
|
||||||
|
DB_ADMIN_PASSWORD="${DB_ADMIN_PASSWORD:-postgres}"
|
||||||
|
|
||||||
|
# Script directory
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
DDL_DIR="$SCRIPT_DIR/../ddl"
|
||||||
|
SEEDS_DIR="$SCRIPT_DIR/../seeds"
|
||||||
|
|
||||||
|
echo -e "${BLUE}============================================${NC}"
|
||||||
|
echo -e "${BLUE} TEMPLATE-SAAS Database Creation${NC}"
|
||||||
|
echo -e "${BLUE}============================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "Host: ${YELLOW}$DB_HOST:$DB_PORT${NC}"
|
||||||
|
echo -e "Database: ${YELLOW}$DB_NAME${NC}"
|
||||||
|
echo -e "User: ${YELLOW}$DB_USER${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Function to run SQL as admin
|
||||||
|
run_sql_admin() {
|
||||||
|
PGPASSWORD="$DB_ADMIN_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_ADMIN_USER" -d postgres -c "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to run SQL file as admin on target DB
|
||||||
|
run_sql_file_admin() {
|
||||||
|
PGPASSWORD="$DB_ADMIN_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_ADMIN_USER" -d "$DB_NAME" -f "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to run SQL file as app user
|
||||||
|
run_sql_file() {
|
||||||
|
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check PostgreSQL connection
|
||||||
|
echo -e "${BLUE}[1/7] Checking PostgreSQL connection...${NC}"
|
||||||
|
if ! PGPASSWORD="$DB_ADMIN_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_ADMIN_USER" -d postgres -c '\q' 2>/dev/null; then
|
||||||
|
echo -e "${RED}ERROR: Cannot connect to PostgreSQL at $DB_HOST:$DB_PORT${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e "${GREEN}✓ PostgreSQL connection successful${NC}"
|
||||||
|
|
||||||
|
# Create user if not exists (skip if admin doesn't have CREATEROLE)
|
||||||
|
echo -e "${BLUE}[2/7] Creating database user...${NC}"
|
||||||
|
USER_EXISTS=$(PGPASSWORD="$DB_ADMIN_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_ADMIN_USER" -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'")
|
||||||
|
if [ "$USER_EXISTS" != "1" ]; then
|
||||||
|
# Check if admin has CREATEROLE privilege
|
||||||
|
CAN_CREATE_ROLE=$(PGPASSWORD="$DB_ADMIN_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_ADMIN_USER" -d postgres -tAc "SELECT rolcreaterole FROM pg_roles WHERE rolname='$DB_ADMIN_USER'")
|
||||||
|
if [ "$CAN_CREATE_ROLE" = "t" ]; then
|
||||||
|
run_sql_admin "CREATE USER $DB_USER WITH PASSWORD '$DB_PASSWORD';"
|
||||||
|
echo -e "${GREEN}✓ User $DB_USER created${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}→ Admin cannot create roles. Using admin user as DB owner.${NC}"
|
||||||
|
DB_USER="$DB_ADMIN_USER"
|
||||||
|
DB_PASSWORD="$DB_ADMIN_PASSWORD"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}→ User $DB_USER already exists${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create database if not exists
|
||||||
|
echo -e "${BLUE}[3/7] Creating database...${NC}"
|
||||||
|
DB_EXISTS=$(PGPASSWORD="$DB_ADMIN_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_ADMIN_USER" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'")
|
||||||
|
if [ "$DB_EXISTS" != "1" ]; then
|
||||||
|
run_sql_admin "CREATE DATABASE $DB_NAME OWNER $DB_USER;"
|
||||||
|
echo -e "${GREEN}✓ Database $DB_NAME created${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}→ Database $DB_NAME already exists${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Grant privileges
|
||||||
|
run_sql_admin "GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;"
|
||||||
|
|
||||||
|
# Load extensions (requires admin)
|
||||||
|
echo -e "${BLUE}[4/7] Loading extensions...${NC}"
|
||||||
|
if [ -f "$DDL_DIR/00-extensions.sql" ]; then
|
||||||
|
run_sql_file_admin "$DDL_DIR/00-extensions.sql"
|
||||||
|
echo -e "${GREEN}✓ Extensions loaded${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}ERROR: 00-extensions.sql not found${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Load base DDL files
|
||||||
|
echo -e "${BLUE}[5/7] Loading DDL (schemas, enums, functions)...${NC}"
|
||||||
|
|
||||||
|
# Schemas
|
||||||
|
if [ -f "$DDL_DIR/01-schemas.sql" ]; then
|
||||||
|
run_sql_file_admin "$DDL_DIR/01-schemas.sql"
|
||||||
|
echo -e "${GREEN} ✓ Schemas created${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Enums
|
||||||
|
if [ -f "$DDL_DIR/02-enums.sql" ]; then
|
||||||
|
run_sql_file_admin "$DDL_DIR/02-enums.sql"
|
||||||
|
echo -e "${GREEN} ✓ Enums created${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Grant schema privileges to user
|
||||||
|
PGPASSWORD="$DB_ADMIN_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_ADMIN_USER" -d "$DB_NAME" -c "
|
||||||
|
GRANT USAGE ON SCHEMA auth TO $DB_USER;
|
||||||
|
GRANT USAGE ON SCHEMA tenants TO $DB_USER;
|
||||||
|
GRANT USAGE ON SCHEMA users TO $DB_USER;
|
||||||
|
GRANT USAGE ON SCHEMA billing TO $DB_USER;
|
||||||
|
GRANT USAGE ON SCHEMA plans TO $DB_USER;
|
||||||
|
GRANT USAGE ON SCHEMA audit TO $DB_USER;
|
||||||
|
GRANT USAGE ON SCHEMA notifications TO $DB_USER;
|
||||||
|
GRANT USAGE ON SCHEMA feature_flags TO $DB_USER;
|
||||||
|
GRANT USAGE ON SCHEMA storage TO $DB_USER;
|
||||||
|
GRANT USAGE ON SCHEMA ai TO $DB_USER;
|
||||||
|
GRANT USAGE ON SCHEMA webhooks TO $DB_USER;
|
||||||
|
GRANT USAGE ON SCHEMA whatsapp TO $DB_USER;
|
||||||
|
"
|
||||||
|
|
||||||
|
# Load schema tables in order
|
||||||
|
echo -e "${BLUE}[6/7] Loading tables...${NC}"
|
||||||
|
|
||||||
|
# Order matters for foreign keys
|
||||||
|
SCHEMA_ORDER=(
|
||||||
|
"tenants"
|
||||||
|
"plans"
|
||||||
|
"users"
|
||||||
|
"auth"
|
||||||
|
"billing"
|
||||||
|
"audit"
|
||||||
|
"notifications"
|
||||||
|
"feature_flags"
|
||||||
|
"ai"
|
||||||
|
"storage"
|
||||||
|
"webhooks"
|
||||||
|
"whatsapp"
|
||||||
|
)
|
||||||
|
|
||||||
|
for schema in "${SCHEMA_ORDER[@]}"; do
|
||||||
|
SCHEMA_DIR="$DDL_DIR/schemas/$schema/tables"
|
||||||
|
if [ -d "$SCHEMA_DIR" ]; then
|
||||||
|
echo -e " Loading schema: ${YELLOW}$schema${NC}"
|
||||||
|
for sql_file in $(ls "$SCHEMA_DIR"/*.sql 2>/dev/null | sort); do
|
||||||
|
run_sql_file_admin "$sql_file"
|
||||||
|
echo -e " ${GREEN}✓${NC} $(basename $sql_file)"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Load functions (after tables exist)
|
||||||
|
if [ -f "$DDL_DIR/03-functions.sql" ]; then
|
||||||
|
run_sql_file_admin "$DDL_DIR/03-functions.sql"
|
||||||
|
echo -e "${GREEN} ✓ Functions created${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Grant table privileges
|
||||||
|
PGPASSWORD="$DB_ADMIN_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_ADMIN_USER" -d "$DB_NAME" -c "
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA auth TO $DB_USER;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA tenants TO $DB_USER;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA users TO $DB_USER;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA billing TO $DB_USER;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA plans TO $DB_USER;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA audit TO $DB_USER;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA notifications TO $DB_USER;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA feature_flags TO $DB_USER;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA ai TO $DB_USER;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA storage TO $DB_USER;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA webhooks TO $DB_USER;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA whatsapp TO $DB_USER;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA billing TO $DB_USER;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA ai TO $DB_USER;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA storage TO $DB_USER;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA webhooks TO $DB_USER;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA whatsapp TO $DB_USER;
|
||||||
|
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA auth TO $DB_USER;
|
||||||
|
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA tenants TO $DB_USER;
|
||||||
|
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA users TO $DB_USER;
|
||||||
|
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA billing TO $DB_USER;
|
||||||
|
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA plans TO $DB_USER;
|
||||||
|
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA audit TO $DB_USER;
|
||||||
|
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA notifications TO $DB_USER;
|
||||||
|
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA feature_flags TO $DB_USER;
|
||||||
|
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA ai TO $DB_USER;
|
||||||
|
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA storage TO $DB_USER;
|
||||||
|
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA webhooks TO $DB_USER;
|
||||||
|
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA whatsapp TO $DB_USER;
|
||||||
|
"
|
||||||
|
|
||||||
|
# Load seeds
|
||||||
|
echo -e "${BLUE}[7/7] Loading seeds...${NC}"
|
||||||
|
|
||||||
|
# Production seeds
|
||||||
|
if [ -d "$SEEDS_DIR/prod" ]; then
|
||||||
|
for sql_file in $(ls "$SEEDS_DIR/prod"/*.sql 2>/dev/null | sort); do
|
||||||
|
run_sql_file_admin "$sql_file"
|
||||||
|
echo -e " ${GREEN}✓${NC} $(basename $sql_file)"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Development seeds (if DEV_SEEDS=1)
|
||||||
|
if [ "$DEV_SEEDS" = "1" ] && [ -d "$SEEDS_DIR/dev" ]; then
|
||||||
|
echo -e " Loading development seeds..."
|
||||||
|
for sql_file in $(ls "$SEEDS_DIR/dev"/*.sql 2>/dev/null | sort); do
|
||||||
|
run_sql_file_admin "$sql_file"
|
||||||
|
echo -e " ${GREEN}✓${NC} $(basename $sql_file)"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}============================================${NC}"
|
||||||
|
echo -e "${GREEN} Database creation completed!${NC}"
|
||||||
|
echo -e "${GREEN}============================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "Connection string:"
|
||||||
|
echo -e "${YELLOW}postgresql://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verify tables
|
||||||
|
echo -e "${BLUE}Verification:${NC}"
|
||||||
|
TABLE_COUNT=$(PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -tAc "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog', 'information_schema');")
|
||||||
|
echo -e " Tables created: ${GREEN}$TABLE_COUNT${NC}"
|
||||||
|
|
||||||
|
PLANS_COUNT=$(PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -tAc "SELECT COUNT(*) FROM plans.plans;")
|
||||||
|
echo -e " Plans seeded: ${GREEN}$PLANS_COUNT${NC}"
|
||||||
|
|
||||||
|
PERMS_COUNT=$(PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -tAc "SELECT COUNT(*) FROM users.permissions;")
|
||||||
|
echo -e " Permissions seeded: ${GREEN}$PERMS_COUNT${NC}"
|
||||||
|
|
||||||
|
FLAGS_COUNT=$(PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -tAc "SELECT COUNT(*) FROM feature_flags.flags;")
|
||||||
|
echo -e " Feature flags seeded: ${GREEN}$FLAGS_COUNT${NC}"
|
||||||
|
|
||||||
|
TEMPLATES_COUNT=$(PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -tAc "SELECT COUNT(*) FROM notifications.templates;")
|
||||||
|
echo -e " Notification templates seeded: ${GREEN}$TEMPLATES_COUNT${NC}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}Done!${NC}"
|
||||||
70
scripts/drop-and-recreate.sh
Executable file
70
scripts/drop-and-recreate.sh
Executable file
@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================
|
||||||
|
# TEMPLATE-SAAS: Drop and Recreate Database
|
||||||
|
# Version: 1.0.0
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Default values
|
||||||
|
DB_HOST="${DB_HOST:-localhost}"
|
||||||
|
DB_PORT="${DB_PORT:-5432}"
|
||||||
|
DB_NAME="${DB_NAME:-template_saas_dev}"
|
||||||
|
DB_USER="${DB_USER:-template_saas_user}"
|
||||||
|
DB_PASSWORD="${DB_PASSWORD:-template_saas_dev_2026}"
|
||||||
|
DB_ADMIN_USER="${DB_ADMIN_USER:-postgres}"
|
||||||
|
DB_ADMIN_PASSWORD="${DB_ADMIN_PASSWORD:-postgres}"
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
echo -e "${BLUE}============================================${NC}"
|
||||||
|
echo -e "${BLUE} TEMPLATE-SAAS Drop & Recreate Database${NC}"
|
||||||
|
echo -e "${BLUE}============================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "Database: ${YELLOW}$DB_NAME${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Confirm
|
||||||
|
if [ "$FORCE" != "1" ]; then
|
||||||
|
echo -e "${RED}WARNING: This will DROP the database $DB_NAME and all its data!${NC}"
|
||||||
|
read -p "Are you sure you want to continue? (y/N) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Aborted."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Drop database
|
||||||
|
echo -e "${BLUE}[1/2] Dropping database...${NC}"
|
||||||
|
|
||||||
|
# Terminate connections
|
||||||
|
PGPASSWORD="$DB_ADMIN_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_ADMIN_USER" -d postgres -c "
|
||||||
|
SELECT pg_terminate_backend(pg_stat_activity.pid)
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE pg_stat_activity.datname = '$DB_NAME'
|
||||||
|
AND pid <> pg_backend_pid();
|
||||||
|
" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Drop database if exists
|
||||||
|
DB_EXISTS=$(PGPASSWORD="$DB_ADMIN_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_ADMIN_USER" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'")
|
||||||
|
if [ "$DB_EXISTS" = "1" ]; then
|
||||||
|
PGPASSWORD="$DB_ADMIN_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_ADMIN_USER" -d postgres -c "DROP DATABASE $DB_NAME;"
|
||||||
|
echo -e "${GREEN}✓ Database $DB_NAME dropped${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}→ Database $DB_NAME does not exist${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Recreate database
|
||||||
|
echo -e "${BLUE}[2/2] Creating database...${NC}"
|
||||||
|
"$SCRIPT_DIR/create-database.sh"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}Database recreated successfully!${NC}"
|
||||||
155
seeds/prod/01-plans.sql
Normal file
155
seeds/prod/01-plans.sql
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Production Seeds - Plans
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Default pricing plans
|
||||||
|
INSERT INTO plans.plans (id, name, slug, description, tagline, price_monthly, price_yearly, trial_days, is_popular, sort_order, features, limits, included_features) VALUES
|
||||||
|
|
||||||
|
-- Free Plan
|
||||||
|
(
|
||||||
|
'a0000000-0000-0000-0000-000000000001',
|
||||||
|
'Free',
|
||||||
|
'free',
|
||||||
|
'Perfect for trying out the platform',
|
||||||
|
'Get started for free',
|
||||||
|
0.00,
|
||||||
|
0.00,
|
||||||
|
0, -- No trial needed
|
||||||
|
FALSE,
|
||||||
|
1,
|
||||||
|
'[
|
||||||
|
{"name": "Users", "value": "1", "highlight": false},
|
||||||
|
{"name": "Storage", "value": "100MB", "highlight": false},
|
||||||
|
{"name": "API Calls", "value": "1,000/mo", "highlight": false},
|
||||||
|
{"name": "Email Support", "value": true, "highlight": false}
|
||||||
|
]'::jsonb,
|
||||||
|
'{
|
||||||
|
"max_users": 1,
|
||||||
|
"max_storage_mb": 100,
|
||||||
|
"max_api_calls_month": 1000,
|
||||||
|
"max_projects": 1
|
||||||
|
}'::jsonb,
|
||||||
|
'["basic_features"]'::jsonb
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Starter Plan
|
||||||
|
(
|
||||||
|
'a0000000-0000-0000-0000-000000000002',
|
||||||
|
'Starter',
|
||||||
|
'starter',
|
||||||
|
'Great for small teams getting started',
|
||||||
|
'Best for small teams',
|
||||||
|
29.00,
|
||||||
|
290.00, -- ~17% discount
|
||||||
|
14,
|
||||||
|
FALSE,
|
||||||
|
2,
|
||||||
|
'[
|
||||||
|
{"name": "Users", "value": "5", "highlight": true},
|
||||||
|
{"name": "Storage", "value": "5GB", "highlight": false},
|
||||||
|
{"name": "API Calls", "value": "10,000/mo", "highlight": false},
|
||||||
|
{"name": "Email Support", "value": true, "highlight": false},
|
||||||
|
{"name": "Basic Analytics", "value": true, "highlight": false}
|
||||||
|
]'::jsonb,
|
||||||
|
'{
|
||||||
|
"max_users": 5,
|
||||||
|
"max_storage_mb": 5120,
|
||||||
|
"max_api_calls_month": 10000,
|
||||||
|
"max_projects": 5
|
||||||
|
}'::jsonb,
|
||||||
|
'["basic_features", "analytics_basic"]'::jsonb
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Pro Plan
|
||||||
|
(
|
||||||
|
'a0000000-0000-0000-0000-000000000003',
|
||||||
|
'Pro',
|
||||||
|
'pro',
|
||||||
|
'For growing teams that need more power',
|
||||||
|
'Most popular',
|
||||||
|
79.00,
|
||||||
|
790.00, -- ~17% discount
|
||||||
|
14,
|
||||||
|
TRUE, -- Popular
|
||||||
|
3,
|
||||||
|
'[
|
||||||
|
{"name": "Users", "value": "25", "highlight": true},
|
||||||
|
{"name": "Storage", "value": "50GB", "highlight": true},
|
||||||
|
{"name": "API Calls", "value": "100,000/mo", "highlight": false},
|
||||||
|
{"name": "Priority Support", "value": true, "highlight": true},
|
||||||
|
{"name": "Advanced Analytics", "value": true, "highlight": false},
|
||||||
|
{"name": "API Access", "value": true, "highlight": false},
|
||||||
|
{"name": "Custom Integrations", "value": true, "highlight": false}
|
||||||
|
]'::jsonb,
|
||||||
|
'{
|
||||||
|
"max_users": 25,
|
||||||
|
"max_storage_mb": 51200,
|
||||||
|
"max_api_calls_month": 100000,
|
||||||
|
"max_projects": 25
|
||||||
|
}'::jsonb,
|
||||||
|
'["basic_features", "analytics_basic", "analytics_advanced", "api_access", "integrations"]'::jsonb
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Enterprise Plan
|
||||||
|
(
|
||||||
|
'a0000000-0000-0000-0000-000000000004',
|
||||||
|
'Enterprise',
|
||||||
|
'enterprise',
|
||||||
|
'For large organizations with custom needs',
|
||||||
|
'Contact sales',
|
||||||
|
NULL, -- Custom pricing
|
||||||
|
NULL,
|
||||||
|
30,
|
||||||
|
FALSE,
|
||||||
|
4,
|
||||||
|
'[
|
||||||
|
{"name": "Users", "value": "Unlimited", "highlight": true},
|
||||||
|
{"name": "Storage", "value": "Unlimited", "highlight": true},
|
||||||
|
{"name": "API Calls", "value": "Unlimited", "highlight": true},
|
||||||
|
{"name": "Dedicated Support", "value": true, "highlight": true},
|
||||||
|
{"name": "SLA", "value": "99.9%", "highlight": true},
|
||||||
|
{"name": "Custom Development", "value": true, "highlight": false},
|
||||||
|
{"name": "On-premise Option", "value": true, "highlight": false}
|
||||||
|
]'::jsonb,
|
||||||
|
'{
|
||||||
|
"max_users": -1,
|
||||||
|
"max_storage_mb": -1,
|
||||||
|
"max_api_calls_month": -1,
|
||||||
|
"max_projects": -1
|
||||||
|
}'::jsonb,
|
||||||
|
'["basic_features", "analytics_basic", "analytics_advanced", "api_access", "integrations", "sso", "audit_logs_extended", "custom_development"]'::jsonb
|
||||||
|
)
|
||||||
|
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
price_monthly = EXCLUDED.price_monthly,
|
||||||
|
price_yearly = EXCLUDED.price_yearly,
|
||||||
|
features = EXCLUDED.features,
|
||||||
|
limits = EXCLUDED.limits,
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
-- Plan features detailed (optional)
|
||||||
|
INSERT INTO plans.plan_features (plan_id, feature_code, feature_name, category, value_type, value_number, display_value, sort_order) VALUES
|
||||||
|
|
||||||
|
-- Free plan features
|
||||||
|
('a0000000-0000-0000-0000-000000000001', 'max_users', 'Maximum Users', 'limits', 'number', 1, '1 user', 1),
|
||||||
|
('a0000000-0000-0000-0000-000000000001', 'storage', 'Storage', 'limits', 'number', 100, '100 MB', 2),
|
||||||
|
|
||||||
|
-- Starter plan features
|
||||||
|
('a0000000-0000-0000-0000-000000000002', 'max_users', 'Maximum Users', 'limits', 'number', 5, '5 users', 1),
|
||||||
|
('a0000000-0000-0000-0000-000000000002', 'storage', 'Storage', 'limits', 'number', 5120, '5 GB', 2),
|
||||||
|
|
||||||
|
-- Pro plan features
|
||||||
|
('a0000000-0000-0000-0000-000000000003', 'max_users', 'Maximum Users', 'limits', 'number', 25, '25 users', 1),
|
||||||
|
('a0000000-0000-0000-0000-000000000003', 'storage', 'Storage', 'limits', 'number', 51200, '50 GB', 2),
|
||||||
|
|
||||||
|
-- Enterprise plan features
|
||||||
|
('a0000000-0000-0000-0000-000000000004', 'max_users', 'Maximum Users', 'limits', 'number', -1, 'Unlimited', 1),
|
||||||
|
('a0000000-0000-0000-0000-000000000004', 'storage', 'Storage', 'limits', 'number', -1, 'Unlimited', 2)
|
||||||
|
|
||||||
|
ON CONFLICT (plan_id, feature_code) DO NOTHING;
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE plans.plans IS 'Default SaaS pricing plans';
|
||||||
74
seeds/prod/02-permissions.sql
Normal file
74
seeds/prod/02-permissions.sql
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Production Seeds - Permissions
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Base permissions
|
||||||
|
INSERT INTO users.permissions (id, code, name, description, category, is_sensitive, requires_owner) VALUES
|
||||||
|
|
||||||
|
-- Users module
|
||||||
|
('a0000001-0000-0000-0000-000000000001', 'users:read', 'View Users', 'Can view user list and profiles', 'users', FALSE, FALSE),
|
||||||
|
('a0000001-0000-0000-0000-000000000002', 'users:write', 'Create/Edit Users', 'Can create and edit users', 'users', FALSE, FALSE),
|
||||||
|
('a0000001-0000-0000-0000-000000000003', 'users:delete', 'Delete Users', 'Can delete users', 'users', TRUE, FALSE),
|
||||||
|
('a0000001-0000-0000-0000-000000000004', 'users:invite', 'Invite Users', 'Can send user invitations', 'users', FALSE, FALSE),
|
||||||
|
|
||||||
|
-- Roles module
|
||||||
|
('a0000001-0000-0000-0000-000000000010', 'roles:read', 'View Roles', 'Can view roles and permissions', 'roles', FALSE, FALSE),
|
||||||
|
('a0000001-0000-0000-0000-000000000011', 'roles:write', 'Create/Edit Roles', 'Can create and edit roles', 'roles', TRUE, FALSE),
|
||||||
|
('a0000001-0000-0000-0000-000000000012', 'roles:delete', 'Delete Roles', 'Can delete roles', 'roles', TRUE, FALSE),
|
||||||
|
('a0000001-0000-0000-0000-000000000013', 'roles:assign', 'Assign Roles', 'Can assign roles to users', 'roles', TRUE, FALSE),
|
||||||
|
|
||||||
|
-- Billing module
|
||||||
|
('a0000001-0000-0000-0000-000000000020', 'billing:read', 'View Billing', 'Can view invoices and payments', 'billing', FALSE, FALSE),
|
||||||
|
('a0000001-0000-0000-0000-000000000021', 'billing:manage', 'Manage Billing', 'Can manage subscription and payment methods', 'billing', TRUE, TRUE),
|
||||||
|
|
||||||
|
-- Settings module
|
||||||
|
('a0000001-0000-0000-0000-000000000030', 'settings:read', 'View Settings', 'Can view tenant settings', 'settings', FALSE, FALSE),
|
||||||
|
('a0000001-0000-0000-0000-000000000031', 'settings:write', 'Edit Settings', 'Can edit tenant settings', 'settings', TRUE, FALSE),
|
||||||
|
|
||||||
|
-- API Keys
|
||||||
|
('a0000001-0000-0000-0000-000000000040', 'api_keys:read', 'View API Keys', 'Can view API keys (masked)', 'api', FALSE, FALSE),
|
||||||
|
('a0000001-0000-0000-0000-000000000041', 'api_keys:write', 'Create/Edit API Keys', 'Can create and manage API keys', 'api', TRUE, FALSE),
|
||||||
|
('a0000001-0000-0000-0000-000000000042', 'api_keys:delete', 'Delete API Keys', 'Can revoke API keys', 'api', TRUE, FALSE),
|
||||||
|
|
||||||
|
-- Audit Logs
|
||||||
|
('a0000001-0000-0000-0000-000000000050', 'audit:read', 'View Audit Logs', 'Can view audit trail', 'audit', FALSE, FALSE),
|
||||||
|
('a0000001-0000-0000-0000-000000000051', 'audit:export', 'Export Audit Logs', 'Can export audit data', 'audit', TRUE, FALSE),
|
||||||
|
|
||||||
|
-- Feature Flags (admin only)
|
||||||
|
('a0000001-0000-0000-0000-000000000060', 'features:read', 'View Features', 'Can view feature flags status', 'features', FALSE, FALSE),
|
||||||
|
('a0000001-0000-0000-0000-000000000061', 'features:manage', 'Manage Features', 'Can toggle feature flags', 'features', TRUE, TRUE),
|
||||||
|
|
||||||
|
-- Notifications
|
||||||
|
('a0000001-0000-0000-0000-000000000070', 'notifications:read', 'View Notifications', 'Can view notifications', 'notifications', FALSE, FALSE),
|
||||||
|
('a0000001-0000-0000-0000-000000000071', 'notifications:manage', 'Manage Notifications', 'Can manage notification settings', 'notifications', FALSE, FALSE)
|
||||||
|
|
||||||
|
ON CONFLICT (code) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
category = EXCLUDED.category;
|
||||||
|
|
||||||
|
-- Default feature flags
|
||||||
|
INSERT INTO feature_flags.flags (id, code, name, description, category, status, rollout_stage, default_value) VALUES
|
||||||
|
|
||||||
|
-- Core features
|
||||||
|
('f0000000-0000-0000-0000-000000000001', 'dark_mode', 'Dark Mode', 'Enable dark mode UI', 'ui', 'enabled', 'general', TRUE),
|
||||||
|
('f0000000-0000-0000-0000-000000000002', 'api_access', 'API Access', 'Enable API access for integrations', 'api', 'enabled', 'general', FALSE),
|
||||||
|
('f0000000-0000-0000-0000-000000000003', 'analytics_basic', 'Basic Analytics', 'Basic analytics dashboard', 'analytics', 'enabled', 'general', TRUE),
|
||||||
|
('f0000000-0000-0000-0000-000000000004', 'analytics_advanced', 'Advanced Analytics', 'Advanced analytics and reports', 'analytics', 'enabled', 'general', FALSE),
|
||||||
|
|
||||||
|
-- Experimental features
|
||||||
|
('f0000000-0000-0000-0000-000000000010', 'ai_assistant', 'AI Assistant', 'AI-powered assistant (beta)', 'experimental', 'percentage', 'beta', FALSE),
|
||||||
|
('f0000000-0000-0000-0000-000000000011', 'new_dashboard', 'New Dashboard', 'Redesigned dashboard (beta)', 'experimental', 'disabled', 'development', FALSE),
|
||||||
|
|
||||||
|
-- Integration features
|
||||||
|
('f0000000-0000-0000-0000-000000000020', 'slack_integration', 'Slack Integration', 'Connect with Slack', 'integrations', 'enabled', 'general', FALSE),
|
||||||
|
('f0000000-0000-0000-0000-000000000021', 'zapier_integration', 'Zapier Integration', 'Connect with Zapier', 'integrations', 'enabled', 'general', FALSE)
|
||||||
|
|
||||||
|
ON CONFLICT (code) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE users.permissions IS 'Base permission definitions for RBAC';
|
||||||
204
seeds/prod/03-notification-templates.sql
Normal file
204
seeds/prod/03-notification-templates.sql
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TEMPLATE-SAAS: Production Seeds - Notification Templates
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
INSERT INTO notifications.templates (id, code, name, description, category, channel, subject, body, body_html, variables) VALUES
|
||||||
|
|
||||||
|
-- Welcome email
|
||||||
|
(
|
||||||
|
'b0000001-0000-0000-0000-000000000001',
|
||||||
|
'welcome_email',
|
||||||
|
'Welcome Email',
|
||||||
|
'Sent when a new user registers',
|
||||||
|
'transactional',
|
||||||
|
'email',
|
||||||
|
'Welcome to {{company_name}}!',
|
||||||
|
'Hi {{user_name}},
|
||||||
|
|
||||||
|
Welcome to {{company_name}}! We''re excited to have you on board.
|
||||||
|
|
||||||
|
Your account has been created successfully. Here''s what you can do next:
|
||||||
|
|
||||||
|
1. Complete your profile
|
||||||
|
2. Explore the dashboard
|
||||||
|
3. Invite your team members
|
||||||
|
|
||||||
|
If you have any questions, feel free to reach out to our support team.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
The {{company_name}} Team',
|
||||||
|
'<h1>Welcome to {{company_name}}!</h1>
|
||||||
|
<p>Hi {{user_name}},</p>
|
||||||
|
<p>Welcome to {{company_name}}! We''re excited to have you on board.</p>
|
||||||
|
<p>Your account has been created successfully. Here''s what you can do next:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Complete your profile</li>
|
||||||
|
<li>Explore the dashboard</li>
|
||||||
|
<li>Invite your team members</li>
|
||||||
|
</ol>
|
||||||
|
<p>If you have any questions, feel free to reach out to our support team.</p>
|
||||||
|
<p>Best regards,<br>The {{company_name}} Team</p>',
|
||||||
|
'[{"name": "user_name", "required": true}, {"name": "company_name", "required": true, "default": "Template SaaS"}]'
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Email verification
|
||||||
|
(
|
||||||
|
'b0000001-0000-0000-0000-000000000002',
|
||||||
|
'email_verification',
|
||||||
|
'Email Verification',
|
||||||
|
'Sent to verify email address',
|
||||||
|
'transactional',
|
||||||
|
'email',
|
||||||
|
'Verify your email address',
|
||||||
|
'Hi {{user_name}},
|
||||||
|
|
||||||
|
Please verify your email address by clicking the link below:
|
||||||
|
|
||||||
|
{{verification_link}}
|
||||||
|
|
||||||
|
This link will expire in {{expiry_hours}} hours.
|
||||||
|
|
||||||
|
If you didn''t create an account, you can safely ignore this email.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
The {{company_name}} Team',
|
||||||
|
'<h2>Verify your email address</h2>
|
||||||
|
<p>Hi {{user_name}},</p>
|
||||||
|
<p>Please verify your email address by clicking the button below:</p>
|
||||||
|
<p><a href="{{verification_link}}" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">Verify Email</a></p>
|
||||||
|
<p>This link will expire in {{expiry_hours}} hours.</p>
|
||||||
|
<p>If you didn''t create an account, you can safely ignore this email.</p>',
|
||||||
|
'[{"name": "user_name", "required": true}, {"name": "verification_link", "required": true}, {"name": "expiry_hours", "required": true, "default": "24"}, {"name": "company_name", "required": true, "default": "Template SaaS"}]'
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Password reset
|
||||||
|
(
|
||||||
|
'b0000001-0000-0000-0000-000000000003',
|
||||||
|
'password_reset',
|
||||||
|
'Password Reset',
|
||||||
|
'Sent when user requests password reset',
|
||||||
|
'transactional',
|
||||||
|
'email',
|
||||||
|
'Reset your password',
|
||||||
|
'Hi {{user_name}},
|
||||||
|
|
||||||
|
We received a request to reset your password. Click the link below to set a new password:
|
||||||
|
|
||||||
|
{{reset_link}}
|
||||||
|
|
||||||
|
This link will expire in {{expiry_hours}} hours.
|
||||||
|
|
||||||
|
If you didn''t request this, you can safely ignore this email. Your password will not be changed.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
The {{company_name}} Team',
|
||||||
|
'<h2>Reset your password</h2>
|
||||||
|
<p>Hi {{user_name}},</p>
|
||||||
|
<p>We received a request to reset your password. Click the button below to set a new password:</p>
|
||||||
|
<p><a href="{{reset_link}}" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">Reset Password</a></p>
|
||||||
|
<p>This link will expire in {{expiry_hours}} hours.</p>
|
||||||
|
<p>If you didn''t request this, you can safely ignore this email.</p>',
|
||||||
|
'[{"name": "user_name", "required": true}, {"name": "reset_link", "required": true}, {"name": "expiry_hours", "required": true, "default": "1"}, {"name": "company_name", "required": true, "default": "Template SaaS"}]'
|
||||||
|
),
|
||||||
|
|
||||||
|
-- User invitation
|
||||||
|
(
|
||||||
|
'b0000001-0000-0000-0000-000000000004',
|
||||||
|
'user_invitation',
|
||||||
|
'User Invitation',
|
||||||
|
'Sent when inviting a new team member',
|
||||||
|
'transactional',
|
||||||
|
'email',
|
||||||
|
'You''ve been invited to join {{tenant_name}}',
|
||||||
|
'Hi,
|
||||||
|
|
||||||
|
{{inviter_name}} has invited you to join {{tenant_name}} on {{company_name}}.
|
||||||
|
|
||||||
|
Click the link below to accept the invitation and create your account:
|
||||||
|
|
||||||
|
{{invitation_link}}
|
||||||
|
|
||||||
|
This invitation will expire in {{expiry_days}} days.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
The {{company_name}} Team',
|
||||||
|
'<h2>You''ve been invited!</h2>
|
||||||
|
<p><strong>{{inviter_name}}</strong> has invited you to join <strong>{{tenant_name}}</strong> on {{company_name}}.</p>
|
||||||
|
<p><a href="{{invitation_link}}" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">Accept Invitation</a></p>
|
||||||
|
<p>This invitation will expire in {{expiry_days}} days.</p>',
|
||||||
|
'[{"name": "inviter_name", "required": true}, {"name": "tenant_name", "required": true}, {"name": "invitation_link", "required": true}, {"name": "expiry_days", "required": true, "default": "7"}, {"name": "company_name", "required": true, "default": "Template SaaS"}]'
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Invoice paid
|
||||||
|
(
|
||||||
|
'b0000001-0000-0000-0000-000000000005',
|
||||||
|
'invoice_paid',
|
||||||
|
'Invoice Paid',
|
||||||
|
'Sent when an invoice is paid',
|
||||||
|
'transactional',
|
||||||
|
'email',
|
||||||
|
'Payment received - Invoice #{{invoice_number}}',
|
||||||
|
'Hi {{user_name}},
|
||||||
|
|
||||||
|
We''ve received your payment for invoice #{{invoice_number}}.
|
||||||
|
|
||||||
|
Amount: {{amount}} {{currency}}
|
||||||
|
Date: {{payment_date}}
|
||||||
|
|
||||||
|
You can download your receipt here: {{receipt_link}}
|
||||||
|
|
||||||
|
Thank you for your business!
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
The {{company_name}} Team',
|
||||||
|
'<h2>Payment Received</h2>
|
||||||
|
<p>Hi {{user_name}},</p>
|
||||||
|
<p>We''ve received your payment for invoice #{{invoice_number}}.</p>
|
||||||
|
<table style="margin: 20px 0;">
|
||||||
|
<tr><td><strong>Amount:</strong></td><td>{{amount}} {{currency}}</td></tr>
|
||||||
|
<tr><td><strong>Date:</strong></td><td>{{payment_date}}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p><a href="{{receipt_link}}">Download Receipt</a></p>
|
||||||
|
<p>Thank you for your business!</p>',
|
||||||
|
'[{"name": "user_name", "required": true}, {"name": "invoice_number", "required": true}, {"name": "amount", "required": true}, {"name": "currency", "required": true, "default": "USD"}, {"name": "payment_date", "required": true}, {"name": "receipt_link", "required": true}, {"name": "company_name", "required": true, "default": "Template SaaS"}]'
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Trial ending soon
|
||||||
|
(
|
||||||
|
'b0000001-0000-0000-0000-000000000006',
|
||||||
|
'trial_ending',
|
||||||
|
'Trial Ending Soon',
|
||||||
|
'Sent when trial is about to end',
|
||||||
|
'transactional',
|
||||||
|
'email',
|
||||||
|
'Your trial ends in {{days_left}} days',
|
||||||
|
'Hi {{user_name}},
|
||||||
|
|
||||||
|
Your free trial of {{company_name}} ends in {{days_left}} days.
|
||||||
|
|
||||||
|
To continue using all features without interruption, please add a payment method and choose a plan.
|
||||||
|
|
||||||
|
{{upgrade_link}}
|
||||||
|
|
||||||
|
If you have any questions, our team is here to help!
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
The {{company_name}} Team',
|
||||||
|
'<h2>Your trial is ending soon</h2>
|
||||||
|
<p>Hi {{user_name}},</p>
|
||||||
|
<p>Your free trial of {{company_name}} ends in <strong>{{days_left}} days</strong>.</p>
|
||||||
|
<p>To continue using all features without interruption, please add a payment method and choose a plan.</p>
|
||||||
|
<p><a href="{{upgrade_link}}" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">Choose a Plan</a></p>',
|
||||||
|
'[{"name": "user_name", "required": true}, {"name": "days_left", "required": true}, {"name": "upgrade_link", "required": true}, {"name": "company_name", "required": true, "default": "Template SaaS"}]'
|
||||||
|
)
|
||||||
|
|
||||||
|
ON CONFLICT (code) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
subject = EXCLUDED.subject,
|
||||||
|
body = EXCLUDED.body,
|
||||||
|
body_html = EXCLUDED.body_html,
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE notifications.templates IS 'Email and notification templates';
|
||||||
Loading…
Reference in New Issue
Block a user