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:
rckrdmrd 2026-01-16 08:07:11 -06:00
parent 293e228945
commit 3ce06fbce4
37 changed files with 4259 additions and 2 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*.log
.DS_Store

185
README.md
View File

@ -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
View 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
View 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
View 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
View 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
View 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.

View 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';

View 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';

View 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';

View 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';

View 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)';

View 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';

View 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';

View 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';

View 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';

View 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';

View 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';

View 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
-- ============================================

View 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';

View 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';

View 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';

View 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';

View 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';

View 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';

View 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';

View 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';

View 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';

View 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';

View 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;

View 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';

View 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
View 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
View 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
View 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';

View 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';

View 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';