refactor: migrate from old db/ to supabase CLI structure

- Migrated database schema from db/migrations to supabase/migrations
- Added Supabase CLI configuration (config.toml, .gitignore)
- Added kiosk role and amenities tables for touch kiosks
- Added notification system for artist alerts
- Added seed data with test locations and staff
- Removed old migration scripts and documentation
- Updated tasks_mg.md with current setup

Features:
- 2 locations: ANCHOR:23 - Via KLAVA and TEST
- Kiosk role for touch screen check-in/booking
- Amenities: coffee, cocktails, mocktails for clients
- Notifications when client arrives
- 1 staff + 4 artists + 1 kiosk per location
- Services: hair, nails, makeup, lashes
This commit is contained in:
Marco Gallegos
2026-01-16 00:01:32 -06:00
parent a2054ba403
commit 18071cfb63
34 changed files with 1168 additions and 8626 deletions

8
supabase/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local

384
supabase/config.toml Normal file
View File

@@ -0,0 +1,384 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "salonOS"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
# Paths to self-signed certificate pair.
# cert_path = "../certs/my-cert.pem"
# key_path = "../certs/my-key.pem"
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# Maximum amount of time to wait for health check when starting the local database.
health_timeout = "2m"
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
# Allow connections via S3 compatible clients
[storage.s3_protocol]
enabled = true
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
# This feature is only available on the hosted platform.
[storage.analytics]
enabled = false
max_namespaces = 5
max_tables = 10
max_catalogs = 2
# Analytics Buckets is available to Supabase Pro plan.
# [storage.analytics.buckets.my-warehouse]
# Store vector embeddings in S3 for large and durable datasets
# This feature is only available on the hosted platform.
[storage.vector]
enabled = false
max_buckets = 10
max_indexes = 5
# Vector Buckets is available to Supabase Pro plan.
# [storage.vector.buckets.documents-openai]
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:<port>/auth/v1).
# jwt_issuer = ""
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
# subject = "Your password has been changed"
# content_path = "./templates/password_changed_notification.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth redirectUrl.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
email_optional = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
# OAuth server configuration
[auth.oauth_server]
# Enable OAuth server functionality
enabled = false
# Path for OAuth consent flow UI
authorization_url_path = "/oauth/consent"
# Allow dynamic client registration
allow_dynamic_registration = false
[edge_runtime]
enabled = true
# Supported request policies: `oneshot`, `per_worker`.
# `per_worker` (default) — enables hot reload during local development.
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
policy = "per_worker"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 2
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"

View File

@@ -0,0 +1,848 @@
-- ============================================
-- SALONOS - CORRECTED FULL DATABASE MIGRATION
-- Ejecutar TODO este archivo en Supabase SQL Editor
-- URL: https://supabase.com/dashboard/project/pvvwbnybkadhreuqijsl/sql
-- ============================================
-- ============================================
-- BEGIN MIGRATION 001: INITIAL SCHEMA
-- ============================================
-- Habilitar UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ENUMS
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_role') THEN
CREATE TYPE user_role AS ENUM ('admin', 'manager', 'staff', 'artist', 'customer');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'customer_tier') THEN
CREATE TYPE customer_tier AS ENUM ('free', 'gold');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_status') THEN
CREATE TYPE booking_status AS ENUM ('pending', 'confirmed', 'cancelled', 'completed', 'no_show');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'invitation_status') THEN
CREATE TYPE invitation_status AS ENUM ('pending', 'used', 'expired');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'resource_type') THEN
CREATE TYPE resource_type AS ENUM ('station', 'room', 'equipment');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_action') THEN
CREATE TYPE audit_action AS ENUM ('create', 'update', 'delete', 'reset_invitations', 'payment', 'status_change');
END IF;
END $$;
-- LOCATIONS
CREATE TABLE IF NOT EXISTS locations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
timezone VARCHAR(50) NOT NULL DEFAULT 'UTC',
address TEXT,
phone VARCHAR(20),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- RESOURCES
CREATE TABLE IF NOT EXISTS resources (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
type resource_type NOT NULL,
capacity INTEGER DEFAULT 1,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- STAFF
CREATE TABLE IF NOT EXISTS staff (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL,
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
role user_role NOT NULL CHECK (role IN ('admin', 'manager', 'staff', 'artist')),
display_name VARCHAR(100) NOT NULL,
phone VARCHAR(20),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, location_id)
);
-- SERVICES
CREATE TABLE IF NOT EXISTS services (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
description TEXT,
duration_minutes INTEGER NOT NULL CHECK (duration_minutes > 0),
base_price DECIMAL(10, 2) NOT NULL CHECK (base_price >= 0),
requires_dual_artist BOOLEAN DEFAULT false,
premium_fee_enabled BOOLEAN DEFAULT false,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- CUSTOMERS
CREATE TABLE IF NOT EXISTS customers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID UNIQUE,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
phone VARCHAR(20),
tier customer_tier DEFAULT 'free',
notes TEXT,
total_spent DECIMAL(10, 2) DEFAULT 0,
total_visits INTEGER DEFAULT 0,
last_visit_date DATE,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- INVITATIONS
CREATE TABLE IF NOT EXISTS invitations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
inviter_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
code VARCHAR(10) UNIQUE NOT NULL,
email VARCHAR(255),
status invitation_status DEFAULT 'pending',
week_start_date DATE NOT NULL,
expiry_date DATE NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- BOOKINGS
CREATE TABLE IF NOT EXISTS bookings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
short_id VARCHAR(6) UNIQUE NOT NULL,
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT,
secondary_artist_id UUID REFERENCES staff(id) ON DELETE SET NULL,
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
service_id UUID NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
start_time_utc TIMESTAMPTZ NOT NULL,
end_time_utc TIMESTAMPTZ NOT NULL,
status booking_status DEFAULT 'pending',
deposit_amount DECIMAL(10, 2) DEFAULT 0,
total_amount DECIMAL(10, 2) NOT NULL,
is_paid BOOLEAN DEFAULT false,
payment_reference VARCHAR(50),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- AUDIT LOGS
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
entity_type VARCHAR(50) NOT NULL,
entity_id UUID NOT NULL,
action audit_action NOT NULL,
old_values JSONB,
new_values JSONB,
performed_by UUID,
performed_by_role user_role,
ip_address INET,
user_agent TEXT,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- INDEXES
CREATE INDEX IF NOT EXISTS idx_locations_active ON locations(is_active);
CREATE INDEX IF NOT EXISTS idx_resources_location ON resources(location_id);
CREATE INDEX IF NOT EXISTS idx_resources_active ON resources(location_id, is_active);
CREATE INDEX IF NOT EXISTS idx_staff_user ON staff(user_id);
CREATE INDEX IF NOT EXISTS idx_staff_location ON staff(location_id);
CREATE INDEX IF NOT EXISTS idx_staff_role ON staff(location_id, role, is_active);
CREATE INDEX IF NOT EXISTS idx_services_active ON services(is_active);
CREATE INDEX IF NOT EXISTS idx_customers_tier ON customers(tier);
CREATE INDEX IF NOT EXISTS idx_customers_email ON customers(email);
CREATE INDEX IF NOT EXISTS idx_customers_active ON customers(is_active);
CREATE INDEX IF NOT EXISTS idx_invitations_inviter ON invitations(inviter_id);
CREATE INDEX IF NOT EXISTS idx_invitations_code ON invitations(code);
CREATE INDEX IF NOT EXISTS idx_invitations_week ON invitations(week_start_date, status);
CREATE INDEX IF NOT EXISTS idx_bookings_customer ON bookings(customer_id);
CREATE INDEX IF NOT EXISTS idx_bookings_staff ON bookings(staff_id);
CREATE INDEX IF NOT EXISTS idx_bookings_secondary_artist ON bookings(secondary_artist_id);
CREATE INDEX IF NOT EXISTS idx_bookings_location ON bookings(location_id);
CREATE INDEX IF NOT EXISTS idx_bookings_resource ON bookings(resource_id);
CREATE INDEX IF NOT EXISTS idx_bookings_time ON bookings(start_time_utc, end_time_utc);
CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status);
CREATE INDEX IF NOT EXISTS idx_bookings_short_id ON bookings(short_id);
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_logs(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_performed ON audit_logs(performed_by);
-- UPDATED_AT TRIGGER FUNCTION
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- UPDATED_AT TRIGGERS
DROP TRIGGER IF EXISTS locations_updated_at ON locations;
CREATE TRIGGER locations_updated_at BEFORE UPDATE ON locations
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
DROP TRIGGER IF EXISTS resources_updated_at ON resources;
CREATE TRIGGER resources_updated_at BEFORE UPDATE ON resources
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
DROP TRIGGER IF EXISTS staff_updated_at ON staff;
CREATE TRIGGER staff_updated_at BEFORE UPDATE ON staff
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
DROP TRIGGER IF EXISTS services_updated_at ON services;
CREATE TRIGGER services_updated_at BEFORE UPDATE ON services
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
DROP TRIGGER IF EXISTS customers_updated_at ON customers;
CREATE TRIGGER customers_updated_at BEFORE UPDATE ON customers
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
DROP TRIGGER IF EXISTS invitations_updated_at ON invitations;
CREATE TRIGGER invitations_updated_at BEFORE UPDATE ON invitations
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
DROP TRIGGER IF EXISTS bookings_updated_at ON bookings;
CREATE TRIGGER bookings_updated_at BEFORE UPDATE ON bookings
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- CONSTRAINTS (Simple ones only - no subqueries)
ALTER TABLE bookings ADD CONSTRAINT IF NOT EXISTS check_booking_time
CHECK (end_time_utc > start_time_utc);
ALTER TABLE invitations ADD CONSTRAINT IF NOT EXISTS check_week_start_is_monday
CHECK (EXTRACT(ISODOW FROM week_start_date) = 1);
-- Trigger for secondary_artist validation (instead of CHECK constraint with subquery)
CREATE OR REPLACE FUNCTION validate_secondary_artist_role()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.secondary_artist_id IS NOT NULL THEN
IF NOT EXISTS (
SELECT 1 FROM staff s
WHERE s.id = NEW.secondary_artist_id AND s.role = 'artist' AND s.is_active = true
) THEN
RAISE EXCEPTION 'secondary_artist_id must reference an active staff member with role ''artist''';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS validate_booking_secondary_artist ON bookings;
CREATE TRIGGER validate_booking_secondary_artist BEFORE INSERT OR UPDATE ON bookings
FOR EACH ROW EXECUTE FUNCTION validate_secondary_artist_role();
-- ============================================
-- BEGIN MIGRATION 002: RLS POLICIES
-- ============================================
-- HELPER FUNCTIONS
CREATE OR REPLACE FUNCTION get_current_user_role()
RETURNS user_role AS $$
DECLARE
current_staff_role user_role;
current_user_id UUID := auth.uid();
BEGIN
SELECT s.role INTO current_staff_role
FROM staff s
WHERE s.user_id = current_user_id
LIMIT 1;
IF current_staff_role IS NOT NULL THEN
RETURN current_staff_role;
END IF;
IF EXISTS (SELECT 1 FROM customers WHERE user_id = current_user_id) THEN
RETURN 'customer';
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION is_staff_or_higher()
RETURNS BOOLEAN AS $$
DECLARE
user_role user_role := get_current_user_role();
BEGIN
RETURN user_role IN ('admin', 'manager', 'staff');
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION is_artist()
RETURNS BOOLEAN AS $$
DECLARE
user_role user_role := get_current_user_role();
BEGIN
RETURN user_role = 'artist';
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION is_customer()
RETURNS BOOLEAN AS $$
DECLARE
user_role user_role := get_current_user_role();
BEGIN
RETURN user_role = 'customer';
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION is_admin()
RETURNS BOOLEAN AS $$
DECLARE
user_role user_role := get_current_user_role();
BEGIN
RETURN user_role = 'admin';
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ENABLE RLS ON ALL TABLES
ALTER TABLE locations ENABLE ROW LEVEL SECURITY;
ALTER TABLE resources ENABLE ROW LEVEL SECURITY;
ALTER TABLE staff ENABLE ROW LEVEL SECURITY;
ALTER TABLE services ENABLE ROW LEVEL SECURITY;
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE invitations ENABLE ROW LEVEL SECURITY;
ALTER TABLE bookings ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
-- LOCATIONS POLICIES
DROP POLICY IF EXISTS "locations_select_staff_higher" ON locations;
CREATE POLICY "locations_select_staff_higher" ON locations
FOR SELECT
USING (is_staff_or_higher() OR is_admin());
DROP POLICY IF EXISTS "locations_modify_admin_manager" ON locations;
CREATE POLICY "locations_modify_admin_manager" ON locations
FOR ALL
USING (get_current_user_role() IN ('admin', 'manager'));
-- RESOURCES POLICIES
DROP POLICY IF EXISTS "resources_select_staff_higher" ON resources;
CREATE POLICY "resources_select_staff_higher" ON resources
FOR SELECT
USING (is_staff_or_higher() OR is_admin());
DROP POLICY IF EXISTS "resources_select_artist" ON resources;
CREATE POLICY "resources_select_artist" ON resources
FOR SELECT
USING (is_artist());
DROP POLICY IF EXISTS "resources_modify_admin_manager" ON resources;
CREATE POLICY "resources_modify_admin_manager" ON resources
FOR ALL
USING (get_current_user_role() IN ('admin', 'manager'));
-- STAFF POLICIES
DROP POLICY IF EXISTS "staff_select_admin_manager" ON staff;
CREATE POLICY "staff_select_admin_manager" ON staff
FOR SELECT
USING (get_current_user_role() IN ('admin', 'manager'));
DROP POLICY IF EXISTS "staff_select_same_location" ON staff;
CREATE POLICY "staff_select_same_location" ON staff
FOR SELECT
USING (
is_staff_or_higher() AND
EXISTS (
SELECT 1 FROM staff s WHERE s.user_id = auth.uid() AND s.location_id = staff.location_id
)
);
DROP POLICY IF EXISTS "staff_select_artist_view_artists" ON staff;
CREATE POLICY "staff_select_artist_view_artists" ON staff
FOR SELECT
USING (
is_artist() AND
EXISTS (
SELECT 1 FROM staff s WHERE s.user_id = auth.uid() AND s.location_id = staff.location_id
) AND
staff.role = 'artist'
);
DROP POLICY IF EXISTS "staff_modify_admin_manager" ON staff;
CREATE POLICY "staff_modify_admin_manager" ON staff
FOR ALL
USING (get_current_user_role() IN ('admin', 'manager'));
-- SERVICES POLICIES
DROP POLICY IF EXISTS "services_select_all" ON services;
CREATE POLICY "services_select_all" ON services
FOR SELECT
USING (is_active = true);
DROP POLICY IF EXISTS "services_all_admin_manager" ON services;
CREATE POLICY "services_all_admin_manager" ON services
FOR ALL
USING (get_current_user_role() IN ('admin', 'manager'));
-- CUSTOMERS POLICIES (RESTRICTED FOR ARTISTS)
DROP POLICY IF EXISTS "customers_select_admin_manager" ON customers;
CREATE POLICY "customers_select_admin_manager" ON customers
FOR SELECT
USING (get_current_user_role() IN ('admin', 'manager'));
DROP POLICY IF EXISTS "customers_select_staff" ON customers;
CREATE POLICY "customers_select_staff" ON customers
FOR SELECT
USING (is_staff_or_higher());
DROP POLICY IF EXISTS "customers_select_artist_restricted" ON customers;
CREATE POLICY "customers_select_artist_restricted" ON customers
FOR SELECT
USING (is_artist());
DROP POLICY IF EXISTS "customers_select_own" ON customers;
CREATE POLICY "customers_select_own" ON customers
FOR SELECT
USING (is_customer() AND user_id = auth.uid());
DROP POLICY IF EXISTS "customers_modify_admin_manager" ON customers;
CREATE POLICY "customers_modify_admin_manager" ON customers
FOR ALL
USING (get_current_user_role() IN ('admin', 'manager'));
DROP POLICY IF EXISTS "customers_modify_staff" ON customers;
CREATE POLICY "customers_modify_staff" ON customers
FOR ALL
USING (is_staff_or_higher());
DROP POLICY IF EXISTS "customers_update_own" ON customers;
CREATE POLICY "customers_update_own" ON customers
FOR UPDATE
USING (is_customer() AND user_id = auth.uid());
-- INVITATIONS POLICIES
DROP POLICY IF EXISTS "invitations_select_admin_manager" ON invitations;
CREATE POLICY "invitations_select_admin_manager" ON invitations
FOR SELECT
USING (get_current_user_role() IN ('admin', 'manager'));
DROP POLICY IF EXISTS "invitations_select_staff" ON invitations;
CREATE POLICY "invitations_select_staff" ON invitations
FOR SELECT
USING (is_staff_or_higher());
DROP POLICY IF EXISTS "invitations_select_own" ON invitations;
CREATE POLICY "invitations_select_own" ON invitations
FOR SELECT
USING (is_customer() AND inviter_id = (SELECT id FROM customers WHERE user_id = auth.uid()));
DROP POLICY IF EXISTS "invitations_modify_admin_manager" ON invitations;
CREATE POLICY "invitations_modify_admin_manager" ON invitations
FOR ALL
USING (get_current_user_role() IN ('admin', 'manager'));
DROP POLICY IF EXISTS "invitations_modify_staff" ON invitations;
CREATE POLICY "invitations_modify_staff" ON invitations
FOR ALL
USING (is_staff_or_higher());
-- BOOKINGS POLICIES
DROP POLICY IF EXISTS "bookings_select_admin_manager" ON bookings;
CREATE POLICY "bookings_select_admin_manager" ON bookings
FOR SELECT
USING (get_current_user_role() IN ('admin', 'manager'));
DROP POLICY IF EXISTS "bookings_select_staff_location" ON bookings;
CREATE POLICY "bookings_select_staff_location" ON bookings
FOR SELECT
USING (
is_staff_or_higher() AND
EXISTS (
SELECT 1 FROM staff s WHERE s.user_id = auth.uid() AND s.location_id = bookings.location_id
)
);
DROP POLICY IF EXISTS "bookings_select_artist_own" ON bookings;
CREATE POLICY "bookings_select_artist_own" ON bookings
FOR SELECT
USING (
is_artist() AND
(staff_id = (SELECT id FROM staff WHERE user_id = auth.uid()) OR
secondary_artist_id = (SELECT id FROM staff WHERE user_id = auth.uid()))
);
DROP POLICY IF EXISTS "bookings_select_own" ON bookings;
CREATE POLICY "bookings_select_own" ON bookings
FOR SELECT
USING (is_customer() AND customer_id = (SELECT id FROM customers WHERE user_id = auth.uid()));
DROP POLICY IF EXISTS "bookings_modify_admin_manager" ON bookings;
CREATE POLICY "bookings_modify_admin_manager" ON bookings
FOR ALL
USING (get_current_user_role() IN ('admin', 'manager'));
DROP POLICY IF EXISTS "bookings_modify_staff_location" ON bookings;
CREATE POLICY "bookings_modify_staff_location" ON bookings
FOR ALL
USING (
is_staff_or_higher() AND
EXISTS (
SELECT 1 FROM staff s WHERE s.user_id = auth.uid() AND s.location_id = bookings.location_id
)
);
DROP POLICY IF EXISTS "bookings_no_modify_artist" ON bookings;
CREATE POLICY "bookings_no_modify_artist" ON bookings
FOR ALL
USING (NOT is_artist());
DROP POLICY IF EXISTS "bookings_create_own" ON bookings;
CREATE POLICY "bookings_create_own" ON bookings
FOR INSERT
WITH CHECK (
is_customer() AND
customer_id = (SELECT id FROM customers WHERE user_id = auth.uid())
);
DROP POLICY IF EXISTS "bookings_update_own" ON bookings;
CREATE POLICY "bookings_update_own" ON bookings
FOR UPDATE
USING (
is_customer() AND
customer_id = (SELECT id FROM customers WHERE user_id = auth.uid())
);
-- AUDIT LOGS POLICIES
DROP POLICY IF EXISTS "audit_logs_select_admin_manager" ON audit_logs;
CREATE POLICY "audit_logs_select_admin_manager" ON audit_logs
FOR SELECT
USING (get_current_user_role() IN ('admin', 'manager'));
DROP POLICY IF EXISTS "audit_logs_select_staff_location" ON audit_logs;
CREATE POLICY "audit_logs_select_staff_location" ON audit_logs
FOR SELECT
USING (
is_staff_or_higher() AND
EXISTS (
SELECT 1 FROM bookings b
JOIN staff s ON s.user_id = auth.uid()
WHERE b.id = audit_logs.entity_id
AND b.location_id = s.location_id
)
);
DROP POLICY IF EXISTS "audit_logs_no_insert" ON audit_logs;
CREATE POLICY "audit_logs_no_insert" ON audit_logs
FOR INSERT
WITH CHECK (false);
-- ============================================
-- BEGIN MIGRATION 003: AUDIT TRIGGERS
-- ============================================
-- SHORT ID GENERATOR
CREATE OR REPLACE FUNCTION generate_short_id()
RETURNS VARCHAR(6) AS $$
DECLARE
chars VARCHAR(36) := '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
short_id VARCHAR(6);
attempts INT := 0;
max_attempts INT := 10;
BEGIN
LOOP
short_id := '';
FOR i IN 1..6 LOOP
short_id := short_id || substr(chars, floor(random() * 36 + 1)::INT, 1);
END LOOP;
IF NOT EXISTS (SELECT 1 FROM bookings WHERE short_id = short_id) THEN
RETURN short_id;
END IF;
attempts := attempts + 1;
IF attempts >= max_attempts THEN
RAISE EXCEPTION 'Failed to generate unique short_id after % attempts', max_attempts;
END IF;
END LOOP;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- INVITATION CODE GENERATOR
CREATE OR REPLACE FUNCTION generate_invitation_code()
RETURNS VARCHAR(10) AS $$
DECLARE
chars VARCHAR(36) := '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
code VARCHAR(10);
attempts INT := 0;
max_attempts INT := 10;
BEGIN
LOOP
code := '';
FOR i IN 1..10 LOOP
code := code || substr(chars, floor(random() * 36 + 1)::INT, 1);
END LOOP;
IF NOT EXISTS (SELECT 1 FROM invitations WHERE code = code) THEN
RETURN code;
END IF;
attempts := attempts + 1;
IF attempts >= max_attempts THEN
RAISE EXCEPTION 'Failed to generate unique invitation code after % attempts', max_attempts;
END IF;
END LOOP;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- WEEK FUNCTIONS
CREATE OR REPLACE FUNCTION get_week_start(date_param DATE DEFAULT CURRENT_DATE)
RETURNS DATE AS $$
BEGIN
RETURN date_param - (EXTRACT(ISODOW FROM date_param)::INT - 1);
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- WEEKLY INVITATION RESET
CREATE OR REPLACE FUNCTION reset_weekly_invitations_for_customer(customer_uuid UUID)
RETURNS INTEGER AS $$
DECLARE
week_start DATE;
invitations_remaining INTEGER := 5;
invitations_created INTEGER := 0;
BEGIN
week_start := get_week_start(CURRENT_DATE);
SELECT COUNT(*) INTO invitations_created
FROM invitations
WHERE inviter_id = customer_uuid
AND week_start_date = week_start;
IF invitations_created = 0 THEN
INSERT INTO invitations (inviter_id, code, week_start_date, expiry_date, status)
SELECT
customer_uuid,
generate_invitation_code(),
week_start,
week_start + INTERVAL '6 days',
'pending'
FROM generate_series(1, 5);
invitations_created := 5;
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
old_values,
new_values,
performed_by,
performed_by_role,
metadata
)
VALUES (
'invitations',
customer_uuid,
'reset_invitations',
'{"week_start": null}'::JSONB,
'{"week_start": "' || week_start || '", "count": 5}'::JSONB,
NULL,
'system',
'{"reset_type": "weekly", "invitations_created": 5}'::JSONB
);
END IF;
RETURN invitations_created;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION reset_all_weekly_invitations()
RETURNS JSONB AS $$
DECLARE
customers_count INTEGER := 0;
invitations_created INTEGER := 0;
result JSONB;
customer_record RECORD;
BEGIN
FOR customer_record IN
SELECT id FROM customers WHERE tier = 'gold' AND is_active = true
LOOP
invitations_created := invitations_created + reset_weekly_invitations_for_customer(customer_record.id);
customers_count := customers_count + 1;
END LOOP;
result := jsonb_build_object(
'customers_processed', customers_count,
'invitations_created', invitations_created,
'executed_at', NOW()::TEXT
);
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
old_values,
new_values,
performed_by,
performed_by_role,
metadata
)
VALUES (
'invitations',
uuid_generate_v4(),
'reset_invitations',
'{}'::JSONB,
result,
NULL,
'system',
'{"reset_type": "weekly_batch"}'::JSONB
);
RETURN result;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- AUDIT LOG TRIGGER FUNCTION
CREATE OR REPLACE FUNCTION log_audit()
RETURNS TRIGGER AS $$
DECLARE
current_user_role_val user_role;
BEGIN
current_user_role_val := get_current_user_role();
IF TG_TABLE_NAME IN ('bookings', 'customers', 'invitations', 'staff', 'services') THEN
IF TG_OP = 'INSERT' THEN
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
old_values,
new_values,
performed_by,
performed_by_role,
metadata
)
VALUES (
TG_TABLE_NAME,
NEW.id,
'create',
NULL,
row_to_json(NEW)::JSONB,
auth.uid(),
current_user_role_val,
jsonb_build_object('operation', TG_OP, 'table_name', TG_TABLE_NAME)
);
ELSIF TG_OP = 'UPDATE' THEN
IF NEW IS DISTINCT FROM OLD THEN
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
old_values,
new_values,
performed_by,
performed_by_role,
metadata
)
VALUES (
TG_TABLE_NAME,
NEW.id,
'update',
row_to_json(OLD)::JSONB,
row_to_json(NEW)::JSONB,
auth.uid(),
current_user_role_val,
jsonb_build_object('operation', TG_OP, 'table_name', TG_TABLE_NAME)
);
END IF;
ELSIF TG_OP = 'DELETE' THEN
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
old_values,
new_values,
performed_by,
performed_by_role,
metadata
)
VALUES (
TG_TABLE_NAME,
OLD.id,
'delete',
row_to_json(OLD)::JSONB,
NULL,
auth.uid(),
current_user_role_val,
jsonb_build_object('operation', TG_OP, 'table_name', TG_TABLE_NAME)
);
END IF;
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
ELSE
RETURN NEW;
END IF;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- APPLY AUDIT LOG TRIGGERS
DROP TRIGGER IF EXISTS audit_bookings ON bookings;
CREATE TRIGGER audit_bookings AFTER INSERT OR UPDATE OR DELETE ON bookings
FOR EACH ROW EXECUTE FUNCTION log_audit();
DROP TRIGGER IF EXISTS audit_customers ON customers;
CREATE TRIGGER audit_customers AFTER INSERT OR UPDATE OR DELETE ON customers
FOR EACH ROW EXECUTE FUNCTION log_audit();
DROP TRIGGER IF EXISTS audit_invitations ON invitations;
CREATE TRIGGER audit_invitations AFTER INSERT OR UPDATE OR DELETE ON invitations
FOR EACH ROW EXECUTE FUNCTION log_audit();
DROP TRIGGER IF EXISTS audit_staff ON staff;
CREATE TRIGGER audit_staff AFTER INSERT OR UPDATE OR DELETE ON staff
FOR EACH ROW EXECUTE FUNCTION log_audit();
DROP TRIGGER IF EXISTS audit_services ON services;
CREATE TRIGGER audit_services AFTER INSERT OR UPDATE OR DELETE ON services
FOR EACH ROW EXECUTE FUNCTION log_audit();
-- AUTOMATIC SHORT ID GENERATION FOR BOOKINGS
CREATE OR REPLACE FUNCTION generate_booking_short_id()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.short_id IS NULL OR NEW.short_id = '' THEN
NEW.short_id := generate_short_id();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS booking_generate_short_id ON bookings;
CREATE TRIGGER booking_generate_short_id BEFORE INSERT ON bookings
FOR EACH ROW EXECUTE FUNCTION generate_booking_short_id();
-- ============================================
-- VERIFICATION
-- ============================================
DO $$
BEGIN
RAISE NOTICE '===========================================';
RAISE NOTICE 'SALONOS - DATABASE MIGRATION COMPLETED';
RAISE NOTICE '===========================================';
RAISE NOTICE 'Tables created: 8';
RAISE NOTICE 'Functions created: 14';
RAISE NOTICE 'Triggers active: 17+';
RAISE NOTICE 'RLS policies configured: 20+';
RAISE NOTICE 'ENUM types created: 6';
RAISE NOTICE '===========================================';
END
$$;

View File

@@ -0,0 +1,860 @@
-- ============================================
-- SALONOS - CORRECTED FULL DATABASE MIGRATION
-- Ejecutar TODO este archivo en Supabase SQL Editor
-- URL: https://supabase.com/dashboard/project/pvvwbnybkadhreuqijsl/sql
-- ============================================
-- ============================================
-- BEGIN MIGRATION 001: INITIAL SCHEMA
-- ============================================
-- UUID extension not needed in Supabase (uses gen_random_uuid)
-- ENUMS
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_role') THEN
CREATE TYPE user_role AS ENUM ('admin', 'manager', 'staff', 'artist', 'customer');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'customer_tier') THEN
CREATE TYPE customer_tier AS ENUM ('free', 'gold', 'black', 'VIP');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_status') THEN
CREATE TYPE booking_status AS ENUM ('pending', 'confirmed', 'cancelled', 'completed', 'no_show');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'invitation_status') THEN
CREATE TYPE invitation_status AS ENUM ('pending', 'used', 'expired');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'resource_type') THEN
CREATE TYPE resource_type AS ENUM ('station', 'room', 'equipment');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_action') THEN
CREATE TYPE audit_action AS ENUM ('create', 'update', 'delete', 'reset_invitations', 'payment', 'status_change');
END IF;
END $$;
-- LOCATIONS
CREATE TABLE IF NOT EXISTS locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
timezone VARCHAR(50) NOT NULL DEFAULT 'UTC',
address TEXT,
phone VARCHAR(20),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- RESOURCES
CREATE TABLE IF NOT EXISTS resources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
type resource_type NOT NULL,
capacity INTEGER DEFAULT 1,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- STAFF
CREATE TABLE IF NOT EXISTS staff (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
role user_role NOT NULL CHECK (role IN ('admin', 'manager', 'staff', 'artist')),
display_name VARCHAR(100) NOT NULL,
phone VARCHAR(20),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, location_id)
);
-- SERVICES
CREATE TABLE IF NOT EXISTS services (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
description TEXT,
duration_minutes INTEGER NOT NULL CHECK (duration_minutes > 0),
base_price DECIMAL(10, 2) NOT NULL CHECK (base_price >= 0),
requires_dual_artist BOOLEAN DEFAULT false,
premium_fee_enabled BOOLEAN DEFAULT false,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- CUSTOMERS
CREATE TABLE IF NOT EXISTS customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
phone VARCHAR(20),
tier customer_tier DEFAULT 'free',
notes TEXT,
total_spent DECIMAL(10, 2) DEFAULT 0,
total_visits INTEGER DEFAULT 0,
last_visit_date DATE,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- INVITATIONS
CREATE TABLE IF NOT EXISTS invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
inviter_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
code VARCHAR(10) UNIQUE NOT NULL,
email VARCHAR(255),
status invitation_status DEFAULT 'pending',
week_start_date DATE NOT NULL,
expiry_date DATE NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- BOOKINGS
CREATE TABLE IF NOT EXISTS bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
short_id VARCHAR(6) UNIQUE NOT NULL,
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT,
secondary_artist_id UUID REFERENCES staff(id) ON DELETE SET NULL,
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
service_id UUID NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
start_time_utc TIMESTAMPTZ NOT NULL,
end_time_utc TIMESTAMPTZ NOT NULL,
status booking_status DEFAULT 'pending',
deposit_amount DECIMAL(10, 2) DEFAULT 0,
total_amount DECIMAL(10, 2) NOT NULL,
is_paid BOOLEAN DEFAULT false,
payment_reference VARCHAR(50),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- AUDIT LOGS
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type VARCHAR(50) NOT NULL,
entity_id UUID NOT NULL,
action audit_action NOT NULL,
old_values JSONB,
new_values JSONB,
performed_by UUID,
performed_by_role user_role,
ip_address INET,
user_agent TEXT,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- INDEXES
CREATE INDEX IF NOT EXISTS idx_locations_active ON locations(is_active);
CREATE INDEX IF NOT EXISTS idx_resources_location ON resources(location_id);
CREATE INDEX IF NOT EXISTS idx_resources_active ON resources(location_id, is_active);
CREATE INDEX IF NOT EXISTS idx_staff_user ON staff(user_id);
CREATE INDEX IF NOT EXISTS idx_staff_location ON staff(location_id);
CREATE INDEX IF NOT EXISTS idx_staff_role ON staff(location_id, role, is_active);
CREATE INDEX IF NOT EXISTS idx_services_active ON services(is_active);
CREATE INDEX IF NOT EXISTS idx_customers_tier ON customers(tier);
CREATE INDEX IF NOT EXISTS idx_customers_email ON customers(email);
CREATE INDEX IF NOT EXISTS idx_customers_active ON customers(is_active);
CREATE INDEX IF NOT EXISTS idx_invitations_inviter ON invitations(inviter_id);
CREATE INDEX IF NOT EXISTS idx_invitations_code ON invitations(code);
CREATE INDEX IF NOT EXISTS idx_invitations_week ON invitations(week_start_date, status);
CREATE INDEX IF NOT EXISTS idx_bookings_customer ON bookings(customer_id);
CREATE INDEX IF NOT EXISTS idx_bookings_staff ON bookings(staff_id);
CREATE INDEX IF NOT EXISTS idx_bookings_secondary_artist ON bookings(secondary_artist_id);
CREATE INDEX IF NOT EXISTS idx_bookings_location ON bookings(location_id);
CREATE INDEX IF NOT EXISTS idx_bookings_resource ON bookings(resource_id);
CREATE INDEX IF NOT EXISTS idx_bookings_time ON bookings(start_time_utc, end_time_utc);
CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status);
CREATE INDEX IF NOT EXISTS idx_bookings_short_id ON bookings(short_id);
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_logs(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_performed ON audit_logs(performed_by);
-- UPDATED_AT TRIGGER FUNCTION
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- UPDATED_AT TRIGGERS
DROP TRIGGER IF EXISTS locations_updated_at ON locations;
CREATE TRIGGER locations_updated_at BEFORE UPDATE ON locations
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
DROP TRIGGER IF EXISTS resources_updated_at ON resources;
CREATE TRIGGER resources_updated_at BEFORE UPDATE ON resources
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
DROP TRIGGER IF EXISTS staff_updated_at ON staff;
CREATE TRIGGER staff_updated_at BEFORE UPDATE ON staff
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
DROP TRIGGER IF EXISTS services_updated_at ON services;
CREATE TRIGGER services_updated_at BEFORE UPDATE ON services
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
DROP TRIGGER IF EXISTS customers_updated_at ON customers;
CREATE TRIGGER customers_updated_at BEFORE UPDATE ON customers
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
DROP TRIGGER IF EXISTS invitations_updated_at ON invitations;
CREATE TRIGGER invitations_updated_at BEFORE UPDATE ON invitations
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
DROP TRIGGER IF EXISTS bookings_updated_at ON bookings;
CREATE TRIGGER bookings_updated_at BEFORE UPDATE ON bookings
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- CONSTRAINTS (Simple ones only - no subqueries)
ALTER TABLE bookings DROP CONSTRAINT IF EXISTS check_booking_time;
ALTER TABLE bookings ADD CONSTRAINT check_booking_time
CHECK (end_time_utc > start_time_utc);
ALTER TABLE invitations DROP CONSTRAINT IF EXISTS check_week_start_is_monday;
ALTER TABLE invitations ADD CONSTRAINT check_week_start_is_monday
CHECK (EXTRACT(ISODOW FROM week_start_date) = 1);
-- Trigger for secondary_artist validation (instead of CHECK constraint with subquery)
CREATE OR REPLACE FUNCTION validate_secondary_artist_role()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.secondary_artist_id IS NOT NULL THEN
IF NOT EXISTS (
SELECT 1 FROM staff s
WHERE s.id = NEW.secondary_artist_id AND s.role = 'artist' AND s.is_active = true
) THEN
RAISE EXCEPTION 'secondary_artist_id must reference an active staff member with role ''artist''';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS validate_booking_secondary_artist ON bookings;
CREATE TRIGGER validate_booking_secondary_artist BEFORE INSERT OR UPDATE ON bookings
FOR EACH ROW EXECUTE FUNCTION validate_secondary_artist_role();
-- ============================================
-- BEGIN MIGRATION 002: RLS POLICIES
-- ============================================
-- HELPER FUNCTIONS
CREATE OR REPLACE FUNCTION get_current_user_role()
RETURNS user_role AS $$
DECLARE
current_staff_role user_role;
current_user_id UUID := auth.uid();
BEGIN
SELECT s.role INTO current_staff_role
FROM staff s
WHERE s.user_id = current_user_id
LIMIT 1;
IF current_staff_role IS NOT NULL THEN
RETURN current_staff_role;
END IF;
IF EXISTS (SELECT 1 FROM customers WHERE user_id = current_user_id) THEN
RETURN 'customer';
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION is_staff_or_higher()
RETURNS BOOLEAN AS $$
DECLARE
user_role user_role := get_current_user_role();
BEGIN
RETURN user_role IN ('admin', 'manager', 'staff');
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION is_artist()
RETURNS BOOLEAN AS $$
DECLARE
user_role user_role := get_current_user_role();
BEGIN
RETURN user_role = 'artist';
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION is_customer()
RETURNS BOOLEAN AS $$
DECLARE
user_role user_role := get_current_user_role();
BEGIN
RETURN user_role = 'customer';
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION is_admin()
RETURNS BOOLEAN AS $$
DECLARE
user_role user_role := get_current_user_role();
BEGIN
RETURN user_role = 'admin';
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ENABLE RLS ON ALL TABLES
ALTER TABLE locations ENABLE ROW LEVEL SECURITY;
ALTER TABLE resources ENABLE ROW LEVEL SECURITY;
ALTER TABLE staff ENABLE ROW LEVEL SECURITY;
ALTER TABLE services ENABLE ROW LEVEL SECURITY;
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE invitations ENABLE ROW LEVEL SECURITY;
ALTER TABLE bookings ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
-- LOCATIONS POLICIES
DROP POLICY IF EXISTS "locations_select_staff_higher" ON locations;
CREATE POLICY "locations_select_staff_higher" ON locations
FOR SELECT
USING (is_staff_or_higher() OR is_admin());
DROP POLICY IF EXISTS "locations_modify_admin_manager" ON locations;
CREATE POLICY "locations_modify_admin_manager" ON locations
FOR ALL
USING (get_current_user_role() IN ('admin', 'manager'));
-- RESOURCES POLICIES
DROP POLICY IF EXISTS "resources_select_staff_higher" ON resources;
CREATE POLICY "resources_select_staff_higher" ON resources
FOR SELECT
USING (is_staff_or_higher() OR is_admin());
DROP POLICY IF EXISTS "resources_select_artist" ON resources;
CREATE POLICY "resources_select_artist" ON resources
FOR SELECT
USING (is_artist());
DROP POLICY IF EXISTS "resources_modify_admin_manager" ON resources;
CREATE POLICY "resources_modify_admin_manager" ON resources
FOR ALL
USING (get_current_user_role() IN ('admin', 'manager'));
-- STAFF POLICIES
DROP POLICY IF EXISTS "staff_select_admin_manager" ON staff;
CREATE POLICY "staff_select_admin_manager" ON staff
FOR SELECT
USING (get_current_user_role() IN ('admin', 'manager'));
DROP POLICY IF EXISTS "staff_select_same_location" ON staff;
CREATE POLICY "staff_select_same_location" ON staff
FOR SELECT
USING (
is_staff_or_higher() AND
EXISTS (
SELECT 1 FROM staff s WHERE s.user_id = auth.uid() AND s.location_id = staff.location_id
)
);
DROP POLICY IF EXISTS "staff_select_artist_view_artists" ON staff;
CREATE POLICY "staff_select_artist_view_artists" ON staff
FOR SELECT
USING (
is_artist() AND
EXISTS (
SELECT 1 FROM staff s WHERE s.user_id = auth.uid() AND s.location_id = staff.location_id
) AND
staff.role = 'artist'
);
DROP POLICY IF EXISTS "staff_modify_admin_manager" ON staff;
CREATE POLICY "staff_modify_admin_manager" ON staff
FOR ALL
USING (get_current_user_role() IN ('admin', 'manager'));
-- SERVICES POLICIES
DROP POLICY IF EXISTS "services_select_all" ON services;
CREATE POLICY "services_select_all" ON services
FOR SELECT
USING (is_active = true);
DROP POLICY IF EXISTS "services_all_admin_manager" ON services;
CREATE POLICY "services_all_admin_manager" ON services
FOR ALL
USING (get_current_user_role() IN ('admin', 'manager'));
-- CUSTOMERS POLICIES (RESTRICTED FOR ARTISTS)
DROP POLICY IF EXISTS "customers_select_admin_manager" ON customers;
CREATE POLICY "customers_select_admin_manager" ON customers
FOR SELECT
USING (get_current_user_role() IN ('admin', 'manager'));
DROP POLICY IF EXISTS "customers_select_staff" ON customers;
CREATE POLICY "customers_select_staff" ON customers
FOR SELECT
USING (is_staff_or_higher());
DROP POLICY IF EXISTS "customers_select_artist_restricted" ON customers;
CREATE POLICY "customers_select_artist_restricted" ON customers
FOR SELECT
USING (is_artist());
DROP POLICY IF EXISTS "customers_select_own" ON customers;
CREATE POLICY "customers_select_own" ON customers
FOR SELECT
USING (is_customer() AND user_id = auth.uid());
DROP POLICY IF EXISTS "customers_modify_admin_manager" ON customers;
CREATE POLICY "customers_modify_admin_manager" ON customers
FOR ALL
USING (get_current_user_role() IN ('admin', 'manager'));
DROP POLICY IF EXISTS "customers_modify_staff" ON customers;
CREATE POLICY "customers_modify_staff" ON customers
FOR ALL
USING (is_staff_or_higher());
DROP POLICY IF EXISTS "customers_update_own" ON customers;
CREATE POLICY "customers_update_own" ON customers
FOR UPDATE
USING (is_customer() AND user_id = auth.uid());
-- INVITATIONS POLICIES
DROP POLICY IF EXISTS "invitations_select_admin_manager" ON invitations;
CREATE POLICY "invitations_select_admin_manager" ON invitations
FOR SELECT
USING (get_current_user_role() IN ('admin', 'manager'));
DROP POLICY IF EXISTS "invitations_select_staff" ON invitations;
CREATE POLICY "invitations_select_staff" ON invitations
FOR SELECT
USING (is_staff_or_higher());
DROP POLICY IF EXISTS "invitations_select_own" ON invitations;
CREATE POLICY "invitations_select_own" ON invitations
FOR SELECT
USING (is_customer() AND inviter_id = (SELECT id FROM customers WHERE user_id = auth.uid()));
DROP POLICY IF EXISTS "invitations_modify_admin_manager" ON invitations;
CREATE POLICY "invitations_modify_admin_manager" ON invitations
FOR ALL
USING (get_current_user_role() IN ('admin', 'manager'));
DROP POLICY IF EXISTS "invitations_modify_staff" ON invitations;
CREATE POLICY "invitations_modify_staff" ON invitations
FOR ALL
USING (is_staff_or_higher());
-- BOOKINGS POLICIES
DROP POLICY IF EXISTS "bookings_select_admin_manager" ON bookings;
CREATE POLICY "bookings_select_admin_manager" ON bookings
FOR SELECT
USING (get_current_user_role() IN ('admin', 'manager'));
DROP POLICY IF EXISTS "bookings_select_staff_location" ON bookings;
CREATE POLICY "bookings_select_staff_location" ON bookings
FOR SELECT
USING (
is_staff_or_higher() AND
EXISTS (
SELECT 1 FROM staff s WHERE s.user_id = auth.uid() AND s.location_id = bookings.location_id
)
);
DROP POLICY IF EXISTS "bookings_select_artist_own" ON bookings;
CREATE POLICY "bookings_select_artist_own" ON bookings
FOR SELECT
USING (
is_artist() AND
(staff_id = (SELECT id FROM staff WHERE user_id = auth.uid()) OR
secondary_artist_id = (SELECT id FROM staff WHERE user_id = auth.uid()))
);
DROP POLICY IF EXISTS "bookings_select_own" ON bookings;
CREATE POLICY "bookings_select_own" ON bookings
FOR SELECT
USING (is_customer() AND customer_id = (SELECT id FROM customers WHERE user_id = auth.uid()));
DROP POLICY IF EXISTS "bookings_modify_admin_manager" ON bookings;
CREATE POLICY "bookings_modify_admin_manager" ON bookings
FOR ALL
USING (get_current_user_role() IN ('admin', 'manager'));
DROP POLICY IF EXISTS "bookings_modify_staff_location" ON bookings;
CREATE POLICY "bookings_modify_staff_location" ON bookings
FOR ALL
USING (
is_staff_or_higher() AND
EXISTS (
SELECT 1 FROM staff s WHERE s.user_id = auth.uid() AND s.location_id = bookings.location_id
)
);
DROP POLICY IF EXISTS "bookings_no_modify_artist" ON bookings;
CREATE POLICY "bookings_no_modify_artist" ON bookings
FOR ALL
USING (NOT is_artist());
DROP POLICY IF EXISTS "bookings_create_own" ON bookings;
CREATE POLICY "bookings_create_own" ON bookings
FOR INSERT
WITH CHECK (
is_customer() AND
customer_id = (SELECT id FROM customers WHERE user_id = auth.uid())
);
DROP POLICY IF EXISTS "bookings_update_own" ON bookings;
CREATE POLICY "bookings_update_own" ON bookings
FOR UPDATE
USING (
is_customer() AND
customer_id = (SELECT id FROM customers WHERE user_id = auth.uid())
);
-- AUDIT LOGS POLICIES
DROP POLICY IF EXISTS "audit_logs_select_admin_manager" ON audit_logs;
CREATE POLICY "audit_logs_select_admin_manager" ON audit_logs
FOR SELECT
USING (get_current_user_role() IN ('admin', 'manager'));
DROP POLICY IF EXISTS "audit_logs_select_staff_location" ON audit_logs;
CREATE POLICY "audit_logs_select_staff_location" ON audit_logs
FOR SELECT
USING (
is_staff_or_higher() AND
EXISTS (
SELECT 1 FROM bookings b
JOIN staff s ON s.user_id = auth.uid()
WHERE b.id = audit_logs.entity_id
AND b.location_id = s.location_id
)
);
DROP POLICY IF EXISTS "audit_logs_no_insert" ON audit_logs;
CREATE POLICY "audit_logs_no_insert" ON audit_logs
FOR INSERT
WITH CHECK (false);
-- ============================================
-- BEGIN MIGRATION 003: AUDIT TRIGGERS
-- ============================================
-- SHORT ID GENERATOR
CREATE OR REPLACE FUNCTION generate_short_id()
RETURNS VARCHAR(6) AS $$
DECLARE
chars VARCHAR(36) := '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
short_id VARCHAR(6);
attempts INT := 0;
max_attempts INT := 10;
BEGIN
LOOP
short_id := '';
FOR i IN 1..6 LOOP
short_id := short_id || substr(chars, floor(random() * 36 + 1)::INT, 1);
END LOOP;
IF NOT EXISTS (SELECT 1 FROM bookings WHERE short_id = short_id) THEN
RETURN short_id;
END IF;
attempts := attempts + 1;
IF attempts >= max_attempts THEN
RAISE EXCEPTION 'Failed to generate unique short_id after % attempts', max_attempts;
END IF;
END LOOP;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- INVITATION CODE GENERATOR
CREATE OR REPLACE FUNCTION generate_invitation_code()
RETURNS VARCHAR(10) AS $$
DECLARE
chars VARCHAR(36) := '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
new_code VARCHAR(10);
attempts INT := 0;
max_attempts INT := 10;
BEGIN
LOOP
new_code := '';
FOR i IN 1..10 LOOP
new_code := new_code || substr(chars, floor(random() * 36 + 1)::INT, 1);
END LOOP;
IF NOT EXISTS (SELECT 1 FROM invitations WHERE code = new_code) THEN
RETURN new_code;
END IF;
attempts := attempts + 1;
IF attempts >= max_attempts THEN
RAISE EXCEPTION 'Failed to generate unique invitation code after % attempts', max_attempts;
END IF;
END LOOP;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- WEEK FUNCTIONS
CREATE OR REPLACE FUNCTION get_week_start(date_param DATE DEFAULT CURRENT_DATE)
RETURNS DATE AS $$
BEGIN
RETURN date_param - (EXTRACT(ISODOW FROM date_param)::INT - 1);
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- WEEKLY INVITATION RESET
CREATE OR REPLACE FUNCTION reset_weekly_invitations_for_customer(customer_uuid UUID)
RETURNS INTEGER AS $$
DECLARE
week_start DATE;
invitations_remaining INTEGER := 5;
invitations_created INTEGER := 0;
BEGIN
week_start := get_week_start(CURRENT_DATE);
SELECT COUNT(*) INTO invitations_created
FROM invitations
WHERE inviter_id = customer_uuid
AND week_start_date = week_start;
IF invitations_created = 0 THEN
INSERT INTO invitations (inviter_id, code, week_start_date, expiry_date, status)
SELECT
customer_uuid,
generate_invitation_code(),
week_start,
week_start + INTERVAL '6 days',
'pending'
FROM generate_series(1, 5);
invitations_created := 5;
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
old_values,
new_values,
performed_by,
performed_by_role,
metadata
)
VALUES (
'invitations',
customer_uuid,
'reset_invitations',
'{"week_start": null}'::JSONB,
jsonb_build_object('week_start', week_start, 'count', 5),
NULL,
'system',
'{"reset_type": "weekly", "invitations_created": 5}'::JSONB
);
END IF;
RETURN invitations_created;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION reset_all_weekly_invitations()
RETURNS JSONB AS $$
DECLARE
customers_count INTEGER := 0;
invitations_created INTEGER := 0;
result JSONB;
customer_record RECORD;
BEGIN
FOR customer_record IN
SELECT id FROM customers WHERE tier = 'gold' AND is_active = true
LOOP
invitations_created := invitations_created + reset_weekly_invitations_for_customer(customer_record.id);
customers_count := customers_count + 1;
END LOOP;
result := jsonb_build_object(
'customers_processed', customers_count,
'invitations_created', invitations_created,
'executed_at', NOW()::TEXT
);
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
old_values,
new_values,
performed_by,
performed_by_role,
metadata
)
VALUES (
'invitations',
gen_random_uuid(),
'reset_invitations',
'{}'::JSONB,
result,
NULL,
'system',
'{"reset_type": "weekly_batch"}'::JSONB
);
RETURN result;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- AUDIT LOG TRIGGER FUNCTION
CREATE OR REPLACE FUNCTION log_audit()
RETURNS TRIGGER AS $$
DECLARE
current_user_role_val user_role;
BEGIN
current_user_role_val := get_current_user_role();
IF TG_TABLE_NAME IN ('bookings', 'customers', 'invitations', 'staff', 'services') THEN
IF TG_OP = 'INSERT' THEN
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
old_values,
new_values,
performed_by,
performed_by_role,
metadata
)
VALUES (
TG_TABLE_NAME,
NEW.id,
'create',
NULL,
row_to_json(NEW)::JSONB,
auth.uid(),
current_user_role_val,
jsonb_build_object('operation', TG_OP, 'table_name', TG_TABLE_NAME)
);
ELSIF TG_OP = 'UPDATE' THEN
IF NEW IS DISTINCT FROM OLD THEN
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
old_values,
new_values,
performed_by,
performed_by_role,
metadata
)
VALUES (
TG_TABLE_NAME,
NEW.id,
'update',
row_to_json(OLD)::JSONB,
row_to_json(NEW)::JSONB,
auth.uid(),
current_user_role_val,
jsonb_build_object('operation', TG_OP, 'table_name', TG_TABLE_NAME)
);
END IF;
ELSIF TG_OP = 'DELETE' THEN
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
old_values,
new_values,
performed_by,
performed_by_role,
metadata
)
VALUES (
TG_TABLE_NAME,
OLD.id,
'delete',
row_to_json(OLD)::JSONB,
NULL,
auth.uid(),
current_user_role_val,
jsonb_build_object('operation', TG_OP, 'table_name', TG_TABLE_NAME)
);
END IF;
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
ELSE
RETURN NEW;
END IF;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- APPLY AUDIT LOG TRIGGERS
DROP TRIGGER IF EXISTS audit_bookings ON bookings;
CREATE TRIGGER audit_bookings AFTER INSERT OR UPDATE OR DELETE ON bookings
FOR EACH ROW EXECUTE FUNCTION log_audit();
DROP TRIGGER IF EXISTS audit_customers ON customers;
CREATE TRIGGER audit_customers AFTER INSERT OR UPDATE OR DELETE ON customers
FOR EACH ROW EXECUTE FUNCTION log_audit();
DROP TRIGGER IF EXISTS audit_invitations ON invitations;
CREATE TRIGGER audit_invitations AFTER INSERT OR UPDATE OR DELETE ON invitations
FOR EACH ROW EXECUTE FUNCTION log_audit();
DROP TRIGGER IF EXISTS audit_staff ON staff;
CREATE TRIGGER audit_staff AFTER INSERT OR UPDATE OR DELETE ON staff
FOR EACH ROW EXECUTE FUNCTION log_audit();
DROP TRIGGER IF EXISTS audit_services ON services;
CREATE TRIGGER audit_services AFTER INSERT OR UPDATE OR DELETE ON services
FOR EACH ROW EXECUTE FUNCTION log_audit();
-- AUTOMATIC SHORT ID GENERATION FOR BOOKINGS
CREATE OR REPLACE FUNCTION generate_booking_short_id()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.short_id IS NULL OR NEW.short_id = '' THEN
NEW.short_id := generate_short_id();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS booking_generate_short_id ON bookings;
CREATE TRIGGER booking_generate_short_id BEFORE INSERT ON bookings
FOR EACH ROW EXECUTE FUNCTION generate_booking_short_id();
-- ============================================
-- VERIFICATION
-- ============================================
DO $$
BEGIN
RAISE NOTICE '===========================================';
RAISE NOTICE 'SALONOS - DATABASE MIGRATION COMPLETED';
RAISE NOTICE '===========================================';
RAISE NOTICE '✅ Tables created: 8';
RAISE NOTICE '✅ Functions created: 14';
RAISE NOTICE '✅ Triggers active: 17+';
RAISE NOTICE '✅ RLS policies configured: 20+';
RAISE NOTICE '✅ ENUM types created: 6';
RAISE NOTICE '===========================================';
RAISE NOTICE 'NEXT STEPS:';
RAISE NOTICE '1. Configure Auth in Supabase Dashboard';
RAISE NOTICE '2. Create test users with specific roles';
RAISE NOTICE '3. Test Short ID generation:';
RAISE NOTICE ' SELECT generate_short_id();';
RAISE NOTICE '4. Test invitation code generation:';
RAISE NOTICE ' SELECT generate_invitation_code();';
RAISE NOTICE '5. Verify tables:';
RAISE NOTICE ' SELECT table_name FROM information_schema.tables';
RAISE NOTICE ' WHERE table_schema = ''public'' ORDER BY table_name;';
RAISE NOTICE '===========================================';
END
$$;

View File

@@ -0,0 +1,393 @@
-- ============================================
-- SEED DE DATOS - SALONOS (IDEMPOTENTE)
-- Ejecutar múltiples veces sin errores
-- ============================================
-- 1. Crear Locations (solo si no existen)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA') THEN
INSERT INTO locations (name, timezone, address, phone, is_active)
VALUES
('ANCHOR:23 - Via KLAVA', 'America/Monterrey', 'Blvd. Moctezuma 2370, Los Pinos 2do y 3er Sector, 25204 Saltillo, Coah.', '+52 81 1234 5678', true);
END IF;
IF NOT EXISTS (SELECT 1 FROM locations WHERE name = 'TEST - Salón Principal') THEN
INSERT INTO locations (name, timezone, address, phone, is_active)
VALUES
('TEST - Salón Principal', 'America/Monterrey', 'Av. Masaryk 123, Polanco, Ciudad de México', '+52 55 2345 6789', true);
END IF;
END $$;
-- 2. Crear Resources (solo si no existen)
DO $$
BEGIN
-- Para ANCHOR:23 - Via KLAVA
FOR i IN 1..3 LOOP
IF NOT EXISTS (
SELECT 1 FROM resources r
JOIN locations l ON l.id = r.location_id
WHERE l.name = 'ANCHOR:23 - Via KLAVA' AND r.name = 'Sillón Pedicure ' || i
) THEN
INSERT INTO resources (location_id, name, type, capacity, is_active)
SELECT id, 'Sillón Pedicure ' || i, 'station', 1, true
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
END IF;
END LOOP;
FOR i IN 1..4 LOOP
IF NOT EXISTS (
SELECT 1 FROM resources r
JOIN locations l ON l.id = r.location_id
WHERE l.name = 'ANCHOR:23 - Via KLAVA' AND r.name = 'Estación Manicure ' || i
) THEN
INSERT INTO resources (location_id, name, type, capacity, is_active)
SELECT id, 'Estación Manicure ' || i, 'station', 1, true
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
END IF;
END LOOP;
IF NOT EXISTS (
SELECT 1 FROM resources r
JOIN locations l ON l.id = r.location_id
WHERE l.name = 'ANCHOR:23 - Via KLAVA' AND r.name = 'Estación Maquillaje'
) THEN
INSERT INTO resources (location_id, name, type, capacity, is_active)
SELECT id, 'Estación Maquillaje', 'station', 1, true
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
END IF;
IF NOT EXISTS (
SELECT 1 FROM resources r
JOIN locations l ON l.id = r.location_id
WHERE l.name = 'ANCHOR:23 - Via KLAVA' AND r.name = 'Cama Pestañas'
) THEN
INSERT INTO resources (location_id, name, type, capacity, is_active)
SELECT id, 'Cama Pestañas', 'station', 1, true
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
END IF;
-- Para TEST - Salón Principal
FOR i IN 1..3 LOOP
IF NOT EXISTS (
SELECT 1 FROM resources r
JOIN locations l ON l.id = r.location_id
WHERE l.name = 'TEST - Salón Principal' AND r.name = 'Sillón Pedicure ' || i
) THEN
INSERT INTO resources (location_id, name, type, capacity, is_active)
SELECT id, 'Sillón Pedicure ' || i, 'station', 1, true
FROM locations WHERE name = 'TEST - Salón Principal';
END IF;
END LOOP;
FOR i IN 1..4 LOOP
IF NOT EXISTS (
SELECT 1 FROM resources r
JOIN locations l ON l.id = r.location_id
WHERE l.name = 'TEST - Salón Principal' AND r.name = 'Estación Manicure ' || i
) THEN
INSERT INTO resources (location_id, name, type, capacity, is_active)
SELECT id, 'Estación Manicure ' || i, 'station', 1, true
FROM locations WHERE name = 'TEST - Salón Principal';
END IF;
END LOOP;
IF NOT EXISTS (
SELECT 1 FROM resources r
JOIN locations l ON l.id = r.location_id
WHERE l.name = 'TEST - Salón Principal' AND r.name = 'Estación Maquillaje'
) THEN
INSERT INTO resources (location_id, name, type, capacity, is_active)
SELECT id, 'Estación Maquillaje', 'station', 1, true
FROM locations WHERE name = 'TEST - Salón Principal';
END IF;
IF NOT EXISTS (
SELECT 1 FROM resources r
JOIN locations l ON l.id = r.location_id
WHERE l.name = 'TEST - Salón Principal' AND r.name = 'Cama Pestañas'
) THEN
INSERT INTO resources (location_id, name, type, capacity, is_active)
SELECT id, 'Cama Pestañas', 'station', 1, true
FROM locations WHERE name = 'TEST - Salón Principal';
END IF;
END $$;
-- 3. Crear Staff (solo si no existen por display_name)
DO $$
BEGIN
-- Admin Principal
IF NOT EXISTS (SELECT 1 FROM staff WHERE display_name = 'Admin Principal') THEN
INSERT INTO staff (user_id, location_id, role, display_name, phone, is_active)
SELECT gen_random_uuid(), id, 'admin', 'Admin Principal', '+52 55 1111 2222', true
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
END IF;
-- ANCHOR:23 - Via KLAVA: 1 Staff + 4 Artists + 1 Kiosk
IF NOT EXISTS (SELECT 1 FROM staff WHERE display_name = 'Staff KLAVA Coordinadora') THEN
INSERT INTO staff (user_id, location_id, role, display_name, phone, is_active)
SELECT gen_random_uuid(), id, 'staff', 'Staff KLAVA Coordinadora', '+52 55 3333 4444', true
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
END IF;
IF NOT EXISTS (SELECT 1 FROM staff WHERE display_name = 'Kiosk KLAVA Principal') THEN
INSERT INTO staff (user_id, location_id, role, display_name, phone, is_active)
SELECT gen_random_uuid(), id, 'kiosk', 'Kiosk KLAVA Principal', '+52 55 3333 0000', true
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
END IF;
IF NOT EXISTS (SELECT 1 FROM staff WHERE display_name = 'Artist KLAVA María García') THEN
INSERT INTO staff (user_id, location_id, role, display_name, phone, is_active)
SELECT gen_random_uuid(), id, 'artist', 'Artist KLAVA María García', '+52 55 4444 5555', true
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
END IF;
IF NOT EXISTS (SELECT 1 FROM staff WHERE display_name = 'Artist KLAVA Ana Rodríguez') THEN
INSERT INTO staff (user_id, location_id, role, display_name, phone, is_active)
SELECT gen_random_uuid(), id, 'artist', 'Artist KLAVA Ana Rodríguez', '+52 55 5555 6666', true
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
END IF;
IF NOT EXISTS (SELECT 1 FROM staff WHERE display_name = 'Artist KLAVA Sofía López') THEN
INSERT INTO staff (user_id, location_id, role, display_name, phone, is_active)
SELECT gen_random_uuid(), id, 'artist', 'Artist KLAVA Sofía López', '+52 55 5555 7777', true
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
END IF;
IF NOT EXISTS (SELECT 1 FROM staff WHERE display_name = 'Artist KLAVA Valentina Ruiz') THEN
INSERT INTO staff (user_id, location_id, role, display_name, phone, is_active)
SELECT gen_random_uuid(), id, 'artist', 'Artist KLAVA Valentina Ruiz', '+52 55 5555 8888', true
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
END IF;
-- TEST - Salón Principal: 1 Staff + 4 Artists + 1 Kiosk
IF NOT EXISTS (SELECT 1 FROM staff WHERE display_name = 'Staff Test Coordinador') THEN
INSERT INTO staff (user_id, location_id, role, display_name, phone, is_active)
SELECT gen_random_uuid(), id, 'staff', 'Staff Test Coordinador', '+52 55 6666 1111', true
FROM locations WHERE name = 'TEST - Salón Principal';
END IF;
IF NOT EXISTS (SELECT 1 FROM staff WHERE display_name = 'Kiosk Test Principal') THEN
INSERT INTO staff (user_id, location_id, role, display_name, phone, is_active)
SELECT gen_random_uuid(), id, 'kiosk', 'Kiosk Test Principal', '+52 55 6666 0000', true
FROM locations WHERE name = 'TEST - Salón Principal';
END IF;
IF NOT EXISTS (SELECT 1 FROM staff WHERE display_name = 'Artist Test Carla López') THEN
INSERT INTO staff (user_id, location_id, role, display_name, phone, is_active)
SELECT gen_random_uuid(), id, 'artist', 'Artist Test Carla López', '+52 55 7777 8888', true
FROM locations WHERE name = 'TEST - Salón Principal';
END IF;
IF NOT EXISTS (SELECT 1 FROM staff WHERE display_name = 'Artist Test Daniela García') THEN
INSERT INTO staff (user_id, location_id, role, display_name, phone, is_active)
SELECT gen_random_uuid(), id, 'artist', 'Artist Test Daniela García', '+52 55 7777 9999', true
FROM locations WHERE name = 'TEST - Salón Principal';
END IF;
IF NOT EXISTS (SELECT 1 FROM staff WHERE display_name = 'Artist Test Andrea Martínez') THEN
INSERT INTO staff (user_id, location_id, role, display_name, phone, is_active)
SELECT gen_random_uuid(), id, 'artist', 'Artist Test Andrea Martínez', '+52 55 7777 0000', true
FROM locations WHERE name = 'TEST - Salón Principal';
END IF;
IF NOT EXISTS (SELECT 1 FROM staff WHERE display_name = 'Artist Test Fernanda Torres') THEN
INSERT INTO staff (user_id, location_id, role, display_name, phone, is_active)
SELECT gen_random_uuid(), id, 'artist', 'Artist Test Fernanda Torres', '+52 55 7777 1111', true
FROM locations WHERE name = 'TEST - Salón Principal';
END IF;
END $$;
-- 4. Crear Services (solo si no existen)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM services WHERE name = 'Corte y Estilizado') THEN
INSERT INTO services (name, description, duration_minutes, base_price, requires_dual_artist, premium_fee_enabled, is_active)
VALUES ('Corte y Estilizado', 'Corte de cabello profesional con lavado y estilizado', 60, 500.00, false, false, true);
END IF;
IF NOT EXISTS (SELECT 1 FROM services WHERE name = 'Color Completo') THEN
INSERT INTO services (name, description, duration_minutes, base_price, requires_dual_artist, premium_fee_enabled, is_active)
VALUES ('Color Completo', 'Tinte completo con protección capilar', 120, 1200.00, false, true, true);
END IF;
IF NOT EXISTS (SELECT 1 FROM services WHERE name = 'Balayage Premium') THEN
INSERT INTO services (name, description, duration_minutes, base_price, requires_dual_artist, premium_fee_enabled, is_active)
VALUES ('Balayage Premium', 'Técnica de balayage con productos premium', 180, 2000.00, true, true, true);
END IF;
IF NOT EXISTS (SELECT 1 FROM services WHERE name = 'Tratamiento Kératina') THEN
INSERT INTO services (name, description, duration_minutes, base_price, requires_dual_artist, premium_fee_enabled, is_active)
VALUES ('Tratamiento Kératina', 'Tratamiento de kératina para cabello dañado', 90, 1500.00, false, false, true);
END IF;
IF NOT EXISTS (SELECT 1 FROM services WHERE name = 'Peinado Evento') THEN
INSERT INTO services (name, description, duration_minutes, base_price, requires_dual_artist, premium_fee_enabled, is_active)
VALUES ('Peinado Evento', 'Peinado para eventos especiales', 45, 800.00, false, true, true);
END IF;
IF NOT EXISTS (SELECT 1 FROM services WHERE name = 'Pedicure Spa') THEN
INSERT INTO services (name, description, duration_minutes, base_price, requires_dual_artist, premium_fee_enabled, is_active)
VALUES ('Pedicure Spa', 'Pedicure completo con exfoliación y mascarilla', 60, 450.00, false, false, true);
END IF;
IF NOT EXISTS (SELECT 1 FROM services WHERE name = 'Manicure Gel') THEN
INSERT INTO services (name, description, duration_minutes, base_price, requires_dual_artist, premium_fee_enabled, is_active)
VALUES ('Manicure Gel', 'Manicure con esmalte de gel duradero', 45, 350.00, false, true, true);
END IF;
IF NOT EXISTS (SELECT 1 FROM services WHERE name = 'Uñas Acrílicas') THEN
INSERT INTO services (name, description, duration_minutes, base_price, requires_dual_artist, premium_fee_enabled, is_active)
VALUES ('Uñas Acrílicas', 'Aplicación de uñas acrílicas con diseño', 120, 800.00, false, true, true);
END IF;
IF NOT EXISTS (SELECT 1 FROM services WHERE name = 'Maquillaje Profesional') THEN
INSERT INTO services (name, description, duration_minutes, base_price, requires_dual_artist, premium_fee_enabled, is_active)
VALUES ('Maquillaje Profesional', 'Maquillaje para eventos especiales', 60, 1200.00, false, true, true);
END IF;
IF NOT EXISTS (SELECT 1 FROM services WHERE name = 'Extensión de Pestañas') THEN
INSERT INTO services (name, description, duration_minutes, base_price, requires_dual_artist, premium_fee_enabled, is_active)
VALUES ('Extensión de Pestañas', 'Aplicación de extensiones pestañas volumen 3D', 90, 1500.00, false, true, true);
END IF;
IF NOT EXISTS (SELECT 1 FROM services WHERE name = 'Servicio Express (Dual Artist)') THEN
INSERT INTO services (name, description, duration_minutes, base_price, requires_dual_artist, premium_fee_enabled, is_active)
VALUES ('Servicio Express (Dual Artist)', 'Servicio rápido con dos artists simultáneas', 30, 600.00, true, true, true);
END IF;
END $$;
-- 5. Crear Customers (solo si no existen por email)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM customers WHERE email = 'sofia.ramirez@example.com') THEN
INSERT INTO customers (user_id, first_name, last_name, email, phone, tier, notes, total_spent, total_visits, last_visit_date, is_active)
VALUES (gen_random_uuid(), 'Sofía', 'Ramírez', 'sofia.ramirez@example.com', '+52 55 1111 1111', 'gold', 'Cliente VIP. Prefiere Artists María y Ana.', 15000.00, 25, '2025-12-20', true);
END IF;
IF NOT EXISTS (SELECT 1 FROM customers WHERE email = 'valentina.hernandez@example.com') THEN
INSERT INTO customers (user_id, first_name, last_name, email, phone, tier, notes, total_spent, total_visits, last_visit_date, is_active)
VALUES (gen_random_uuid(), 'Valentina', 'Hernández', 'valentina.hernandez@example.com', '+52 55 2222 2222', 'gold', 'Cliente regular. Prefiere horarios de la mañana.', 8500.00, 15, '2025-12-15', true);
END IF;
IF NOT EXISTS (SELECT 1 FROM customers WHERE email = 'camila.lopez@example.com') THEN
INSERT INTO customers (user_id, first_name, last_name, email, phone, tier, notes, total_spent, total_visits, last_visit_date, is_active)
VALUES (gen_random_uuid(), 'Camila', 'López', 'camila.lopez@example.com', '+52 55 3333 3333', 'free', 'Nueva cliente. Referida por Valentina.', 500.00, 1, '2025-12-10', true);
END IF;
IF NOT EXISTS (SELECT 1 FROM customers WHERE email = 'isabella.garcia@example.com') THEN
INSERT INTO customers (user_id, first_name, last_name, email, phone, tier, notes, total_spent, total_visits, last_visit_date, is_active)
VALUES (gen_random_uuid(), 'Isabella', 'García', 'isabella.garcia@example.com', '+52 55 4444 4444', 'gold', 'Cliente VIP. Requiere servicio de Balayage.', 22000.00, 30, '2025-12-18', true);
END IF;
END $$;
-- 6. Crear Amenities (cortesías para kiosks)
DO $$
BEGIN
-- ANCHOR:23 - Via KLAVA Amenities
IF NOT EXISTS (SELECT 1 FROM amenities WHERE name = 'Café Americano' AND location_id = (SELECT id FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA' LIMIT 1)) THEN
INSERT INTO amenities (location_id, name, description, category, is_active)
SELECT id, 'Café Americano', 'Café negro tradicional', 'coffee', true
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
END IF;
IF NOT EXISTS (SELECT 1 FROM amenities WHERE name = 'Café Latte' AND location_id = (SELECT id FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA' LIMIT 1)) THEN
INSERT INTO amenities (location_id, name, description, category, is_active)
SELECT id, 'Café Latte', 'Café con leche vaporizada', 'coffee', true
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
END IF;
IF NOT EXISTS (SELECT 1 FROM amenities WHERE name = 'Té Verde' AND location_id = (SELECT id FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA' LIMIT 1)) THEN
INSERT INTO amenities (location_id, name, description, category, is_active)
SELECT id, 'Té Verde', 'Té verde orgánico', 'coffee', true
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
END IF;
IF NOT EXISTS (SELECT 1 FROM amenities WHERE name = 'Cocktail Mojito' AND location_id = (SELECT id FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA' LIMIT 1)) THEN
INSERT INTO amenities (location_id, name, description, category, is_active)
SELECT id, 'Cocktail Mojito', 'Refresco de menta y limón', 'cocktail', true
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
END IF;
IF NOT EXISTS (SELECT 1 FROM amenities WHERE name = 'Mocktail Piña Colada' AND location_id = (SELECT id FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA' LIMIT 1)) THEN
INSERT INTO amenities (location_id, name, description, category, is_active)
SELECT id, 'Mocktail Piña Colada', 'Bebida tropical sin alcohol', 'mocktail', true
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
END IF;
IF NOT EXISTS (SELECT 1 FROM amenities WHERE name = 'Agua Mineral' AND location_id = (SELECT id FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA' LIMIT 1)) THEN
INSERT INTO amenities (location_id, name, description, category, is_active)
SELECT id, 'Agua Mineral', 'Agua mineral fresca', 'other', true
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
END IF;
-- TEST - Salón Principal Amenities
IF NOT EXISTS (SELECT 1 FROM amenities WHERE name = 'Café Espresso' AND location_id = (SELECT id FROM locations WHERE name = 'TEST - Salón Principal' LIMIT 1)) THEN
INSERT INTO amenities (location_id, name, description, category, is_active)
SELECT id, 'Café Espresso', 'Café espresso intenso', 'coffee', true
FROM locations WHERE name = 'TEST - Salón Principal';
END IF;
IF NOT EXISTS (SELECT 1 FROM amenities WHERE name = 'Café Cappuccino' AND location_id = (SELECT id FROM locations WHERE name = 'TEST - Salón Principal' LIMIT 1)) THEN
INSERT INTO amenities (location_id, name, description, category, is_active)
SELECT id, 'Café Cappuccino', 'Café con espuma de leche', 'coffee', true
FROM locations WHERE name = 'TEST - Salón Principal';
END IF;
IF NOT EXISTS (SELECT 1 FROM amenities WHERE name = 'Té Chai' AND location_id = (SELECT id FROM locations WHERE name = 'TEST - Salón Principal' LIMIT 1)) THEN
INSERT INTO amenities (location_id, name, description, category, is_active)
SELECT id, 'Té Chai', 'Té chai con especias', 'coffee', true
FROM locations WHERE name = 'TEST - Salón Principal';
END IF;
IF NOT EXISTS (SELECT 1 FROM amenities WHERE name = 'Cocktail Margarita' AND location_id = (SELECT id FROM locations WHERE name = 'TEST - Salón Principal' LIMIT 1)) THEN
INSERT INTO amenities (location_id, name, description, category, is_active)
SELECT id, 'Cocktail Margarita', 'Cóctel clásico de tequila', 'cocktail', true
FROM locations WHERE name = 'TEST - Salón Principal';
END IF;
IF NOT EXISTS (SELECT 1 FROM amenities WHERE name = 'Mocktail Limonada' AND location_id = (SELECT id FROM locations WHERE name = 'TEST - Salón Principal' LIMIT 1)) THEN
INSERT INTO amenities (location_id, name, description, category, is_active)
SELECT id, 'Mocktail Limonada', 'Limonada fresca natural', 'mocktail', true
FROM locations WHERE name = 'TEST - Salón Principal';
END IF;
IF NOT EXISTS (SELECT 1 FROM amenities WHERE name = 'Revista de Moda' AND location_id = (SELECT id FROM locations WHERE name = 'TEST - Salón Principal' LIMIT 1)) THEN
INSERT INTO amenities (location_id, name, description, category, is_active)
SELECT id, 'Revista de Moda', 'Revistas de moda actual', 'other', true
FROM locations WHERE name = 'TEST - Salón Principal';
END IF;
END $$;
-- 7. Crear Invitaciones (para clientes Gold)
DO $$
DECLARE
week_start DATE;
customer_record RECORD;
BEGIN
week_start := get_week_start(CURRENT_DATE);
FOR customer_record IN
SELECT id FROM customers WHERE tier = 'gold' AND is_active = true
LOOP
PERFORM reset_weekly_invitations_for_customer(customer_record.id);
END LOOP;
END $$;
-- 8. Resumen de datos creados
DO $$
BEGIN
RAISE NOTICE '==========================================';
RAISE NOTICE 'SALONOS - SEED DE DATOS COMPLETADO';
RAISE NOTICE '==========================================';
RAISE NOTICE 'Locations: %', (SELECT COUNT(*) FROM locations);
RAISE NOTICE 'Resources: %', (SELECT COUNT(*) FROM resources);
RAISE NOTICE 'Staff: %', (SELECT COUNT(*) FROM staff);
RAISE NOTICE 'Services: %', (SELECT COUNT(*) FROM services);
RAISE NOTICE 'Customers: %', (SELECT COUNT(*) FROM customers);
RAISE NOTICE 'Amenities: %', (SELECT COUNT(*) FROM amenities);
RAISE NOTICE '==========================================';
RAISE NOTICE 'Base de datos lista para desarrollo';
RAISE NOTICE '==========================================';
END
$$;