diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1330247 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.log +.DS_Store diff --git a/README.md b/README.md index db1bb0b..a82a545 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,184 @@ -# template-saas-database-v2 +# Template SaaS - Database -Database de template-saas - Workspace V2 \ No newline at end of file +**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 diff --git a/ddl/00-extensions.sql b/ddl/00-extensions.sql new file mode 100644 index 0000000..e5c4296 --- /dev/null +++ b/ddl/00-extensions.sql @@ -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'; diff --git a/ddl/01-schemas.sql b/ddl/01-schemas.sql new file mode 100644 index 0000000..31eb0f1 --- /dev/null +++ b/ddl/01-schemas.sql @@ -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'; diff --git a/ddl/02-enums.sql b/ddl/02-enums.sql new file mode 100644 index 0000000..9027c06 --- /dev/null +++ b/ddl/02-enums.sql @@ -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'); diff --git a/ddl/03-functions.sql b/ddl/03-functions.sql new file mode 100644 index 0000000..42ae182 --- /dev/null +++ b/ddl/03-functions.sql @@ -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'; diff --git a/ddl/schemas/ai/_MAP.md b/ddl/schemas/ai/_MAP.md new file mode 100644 index 0000000..d1207f4 --- /dev/null +++ b/ddl/schemas/ai/_MAP.md @@ -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. diff --git a/ddl/schemas/ai/tables/01-ai-configs.sql b/ddl/schemas/ai/tables/01-ai-configs.sql new file mode 100644 index 0000000..16f807d --- /dev/null +++ b/ddl/schemas/ai/tables/01-ai-configs.sql @@ -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'; diff --git a/ddl/schemas/ai/tables/02-ai-usage.sql b/ddl/schemas/ai/tables/02-ai-usage.sql new file mode 100644 index 0000000..0cbe2e5 --- /dev/null +++ b/ddl/schemas/ai/tables/02-ai-usage.sql @@ -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'; diff --git a/ddl/schemas/audit/tables/01-audit-logs.sql b/ddl/schemas/audit/tables/01-audit-logs.sql new file mode 100644 index 0000000..65cd0b8 --- /dev/null +++ b/ddl/schemas/audit/tables/01-audit-logs.sql @@ -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'; diff --git a/ddl/schemas/auth/tables/01-sessions.sql b/ddl/schemas/auth/tables/01-sessions.sql new file mode 100644 index 0000000..30df5de --- /dev/null +++ b/ddl/schemas/auth/tables/01-sessions.sql @@ -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'; diff --git a/ddl/schemas/auth/tables/02-tokens.sql b/ddl/schemas/auth/tables/02-tokens.sql new file mode 100644 index 0000000..7139abb --- /dev/null +++ b/ddl/schemas/auth/tables/02-tokens.sql @@ -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)'; diff --git a/ddl/schemas/auth/tables/03-oauth.sql b/ddl/schemas/auth/tables/03-oauth.sql new file mode 100644 index 0000000..634c87f --- /dev/null +++ b/ddl/schemas/auth/tables/03-oauth.sql @@ -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'; diff --git a/ddl/schemas/billing/tables/01-subscriptions.sql b/ddl/schemas/billing/tables/01-subscriptions.sql new file mode 100644 index 0000000..2592736 --- /dev/null +++ b/ddl/schemas/billing/tables/01-subscriptions.sql @@ -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'; diff --git a/ddl/schemas/billing/tables/02-invoices.sql b/ddl/schemas/billing/tables/02-invoices.sql new file mode 100644 index 0000000..4ade6f5 --- /dev/null +++ b/ddl/schemas/billing/tables/02-invoices.sql @@ -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'; diff --git a/ddl/schemas/billing/tables/03-payment-methods.sql b/ddl/schemas/billing/tables/03-payment-methods.sql new file mode 100644 index 0000000..43b9ae9 --- /dev/null +++ b/ddl/schemas/billing/tables/03-payment-methods.sql @@ -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'; diff --git a/ddl/schemas/feature_flags/tables/01-flags.sql b/ddl/schemas/feature_flags/tables/01-flags.sql new file mode 100644 index 0000000..e9845fb --- /dev/null +++ b/ddl/schemas/feature_flags/tables/01-flags.sql @@ -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'; diff --git a/ddl/schemas/notifications/tables/01-notifications.sql b/ddl/schemas/notifications/tables/01-notifications.sql new file mode 100644 index 0000000..2297adc --- /dev/null +++ b/ddl/schemas/notifications/tables/01-notifications.sql @@ -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'; diff --git a/ddl/schemas/notifications/tables/02-extended-notifications.sql b/ddl/schemas/notifications/tables/02-extended-notifications.sql new file mode 100644 index 0000000..f543de1 --- /dev/null +++ b/ddl/schemas/notifications/tables/02-extended-notifications.sql @@ -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 +-- ============================================ diff --git a/ddl/schemas/plans/tables/01-plans.sql b/ddl/schemas/plans/tables/01-plans.sql new file mode 100644 index 0000000..39feef7 --- /dev/null +++ b/ddl/schemas/plans/tables/01-plans.sql @@ -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'; diff --git a/ddl/schemas/storage/tables/01-files.sql b/ddl/schemas/storage/tables/01-files.sql new file mode 100644 index 0000000..abc70a1 --- /dev/null +++ b/ddl/schemas/storage/tables/01-files.sql @@ -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'; diff --git a/ddl/schemas/storage/tables/02-storage-usage.sql b/ddl/schemas/storage/tables/02-storage-usage.sql new file mode 100644 index 0000000..e7c0765 --- /dev/null +++ b/ddl/schemas/storage/tables/02-storage-usage.sql @@ -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'; diff --git a/ddl/schemas/tenants/tables/01-tenants.sql b/ddl/schemas/tenants/tables/01-tenants.sql new file mode 100644 index 0000000..af295bd --- /dev/null +++ b/ddl/schemas/tenants/tables/01-tenants.sql @@ -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'; diff --git a/ddl/schemas/tenants/tables/02-tenant-settings.sql b/ddl/schemas/tenants/tables/02-tenant-settings.sql new file mode 100644 index 0000000..7bb1973 --- /dev/null +++ b/ddl/schemas/tenants/tables/02-tenant-settings.sql @@ -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'; diff --git a/ddl/schemas/users/tables/01-users.sql b/ddl/schemas/users/tables/01-users.sql new file mode 100644 index 0000000..f462237 --- /dev/null +++ b/ddl/schemas/users/tables/01-users.sql @@ -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'; diff --git a/ddl/schemas/users/tables/02-roles.sql b/ddl/schemas/users/tables/02-roles.sql new file mode 100644 index 0000000..63d3e59 --- /dev/null +++ b/ddl/schemas/users/tables/02-roles.sql @@ -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'; diff --git a/ddl/schemas/users/tables/03-invitations.sql b/ddl/schemas/users/tables/03-invitations.sql new file mode 100644 index 0000000..3c84ff8 --- /dev/null +++ b/ddl/schemas/users/tables/03-invitations.sql @@ -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'; diff --git a/ddl/schemas/webhooks/tables/01-webhooks.sql b/ddl/schemas/webhooks/tables/01-webhooks.sql new file mode 100644 index 0000000..f84736d --- /dev/null +++ b/ddl/schemas/webhooks/tables/01-webhooks.sql @@ -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'; diff --git a/ddl/schemas/webhooks/tables/02-deliveries.sql b/ddl/schemas/webhooks/tables/02-deliveries.sql new file mode 100644 index 0000000..bfd8566 --- /dev/null +++ b/ddl/schemas/webhooks/tables/02-deliveries.sql @@ -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'; diff --git a/ddl/schemas/whatsapp/00-schema.sql b/ddl/schemas/whatsapp/00-schema.sql new file mode 100644 index 0000000..83c5c3f --- /dev/null +++ b/ddl/schemas/whatsapp/00-schema.sql @@ -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; diff --git a/ddl/schemas/whatsapp/tables/01-whatsapp-configs.sql b/ddl/schemas/whatsapp/tables/01-whatsapp-configs.sql new file mode 100644 index 0000000..12b7047 --- /dev/null +++ b/ddl/schemas/whatsapp/tables/01-whatsapp-configs.sql @@ -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'; diff --git a/ddl/schemas/whatsapp/tables/02-whatsapp-messages.sql b/ddl/schemas/whatsapp/tables/02-whatsapp-messages.sql new file mode 100644 index 0000000..4fddef9 --- /dev/null +++ b/ddl/schemas/whatsapp/tables/02-whatsapp-messages.sql @@ -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)'; diff --git a/scripts/create-database.sh b/scripts/create-database.sh new file mode 100755 index 0000000..d223e5a --- /dev/null +++ b/scripts/create-database.sh @@ -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}" diff --git a/scripts/drop-and-recreate.sh b/scripts/drop-and-recreate.sh new file mode 100755 index 0000000..8169adc --- /dev/null +++ b/scripts/drop-and-recreate.sh @@ -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}" diff --git a/seeds/prod/01-plans.sql b/seeds/prod/01-plans.sql new file mode 100644 index 0000000..3232eee --- /dev/null +++ b/seeds/prod/01-plans.sql @@ -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'; diff --git a/seeds/prod/02-permissions.sql b/seeds/prod/02-permissions.sql new file mode 100644 index 0000000..14080c0 --- /dev/null +++ b/seeds/prod/02-permissions.sql @@ -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'; diff --git a/seeds/prod/03-notification-templates.sql b/seeds/prod/03-notification-templates.sql new file mode 100644 index 0000000..6693ccd --- /dev/null +++ b/seeds/prod/03-notification-templates.sql @@ -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', + '
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:
+If you have any questions, feel free to reach out to our support team.
+Best regards,
The {{company_name}} Team
Hi {{user_name}},
+Please verify your email address by clicking the button below:
+ +This link will expire in {{expiry_hours}} hours.
+If you didn''t create an account, you can safely ignore this email.
', + '[{"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', + 'Hi {{user_name}},
+We received a request to reset your password. Click the button below to set a new password:
+ +This link will expire in {{expiry_hours}} hours.
+If you didn''t request this, you can safely ignore this email.
', + '[{"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', + '{{inviter_name}} has invited you to join {{tenant_name}} on {{company_name}}.
+ +This invitation will expire in {{expiry_days}} days.
', + '[{"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', + 'Hi {{user_name}},
+We''ve received your payment for invoice #{{invoice_number}}.
+| Amount: | {{amount}} {{currency}} |
| Date: | {{payment_date}} |
Thank you for your business!
', + '[{"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', + '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.
+', + '[{"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';