trading-platform-database-v2/ddl/schemas/teams/tables/002_invitations.sql
rckrdmrd e520268348 Migración desde trading-platform/apps/database - Estándar multi-repo v2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:32:52 -06:00

372 lines
12 KiB
PL/PgSQL

-- ============================================================================
-- Teams Schema: Invitations Table
-- Manages team/organization invitations
-- ============================================================================
-- ============================================================================
-- INVITATIONS TABLE
-- Stores pending and processed invitations
-- ============================================================================
CREATE TABLE IF NOT EXISTS teams.invitations (
-- Primary key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Tenant relationship
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
-- Invitation details
email VARCHAR(255) NOT NULL,
token_hash VARCHAR(255) NOT NULL,
-- Invitee info (pre-filled for registration)
first_name VARCHAR(100),
last_name VARCHAR(100),
-- Role to assign upon acceptance
role_id UUID REFERENCES rbac.roles(id) ON DELETE SET NULL,
-- Department/position
department VARCHAR(100),
job_title VARCHAR(100),
-- Invitation message
personal_message TEXT,
-- Status
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'declined', 'expired', 'revoked')),
-- Expiration
expires_at TIMESTAMPTZ NOT NULL,
-- Tracking
sent_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
resent_count INTEGER NOT NULL DEFAULT 0,
last_resent_at TIMESTAMPTZ,
-- Response tracking
responded_at TIMESTAMPTZ,
accepted_user_id UUID REFERENCES users.users(id),
-- Audit fields
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
invited_by UUID NOT NULL REFERENCES users.users(id),
revoked_at TIMESTAMPTZ,
revoked_by UUID REFERENCES users.users(id),
-- Constraints
CONSTRAINT uq_invitation_token UNIQUE (token_hash),
CONSTRAINT uq_pending_invitation UNIQUE (tenant_id, email, status)
WHERE status = 'pending'
);
-- ============================================================================
-- INDEXES
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_invitations_tenant_id ON teams.invitations(tenant_id);
CREATE INDEX IF NOT EXISTS idx_invitations_email ON teams.invitations(email);
CREATE INDEX IF NOT EXISTS idx_invitations_token_hash ON teams.invitations(token_hash);
CREATE INDEX IF NOT EXISTS idx_invitations_status ON teams.invitations(status);
CREATE INDEX IF NOT EXISTS idx_invitations_expires_at ON teams.invitations(expires_at) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_invitations_invited_by ON teams.invitations(invited_by);
-- ============================================================================
-- ROW LEVEL SECURITY
-- ============================================================================
ALTER TABLE teams.invitations ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see invitations in their tenant
CREATE POLICY invitations_tenant_isolation ON teams.invitations
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- ============================================================================
-- TRIGGERS
-- ============================================================================
-- Auto-update updated_at timestamp
CREATE OR REPLACE FUNCTION teams.update_invitations_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_invitations_updated_at
BEFORE UPDATE ON teams.invitations
FOR EACH ROW
EXECUTE FUNCTION teams.update_invitations_timestamp();
-- ============================================================================
-- VIEW: Invitations with details
-- ============================================================================
CREATE OR REPLACE VIEW teams.v_invitations AS
SELECT
i.id,
i.tenant_id,
t.name AS tenant_name,
i.email,
i.first_name,
i.last_name,
i.role_id,
r.name AS role_name,
i.department,
i.job_title,
i.personal_message,
i.status,
i.expires_at,
i.sent_at,
i.resent_count,
i.last_resent_at,
i.responded_at,
i.created_at,
i.invited_by,
inviter.email AS invited_by_email,
inviter.display_name AS invited_by_name,
CASE
WHEN i.status = 'pending' AND i.expires_at < CURRENT_TIMESTAMP THEN true
ELSE false
END AS is_expired
FROM teams.invitations i
JOIN tenants.tenants t ON i.tenant_id = t.id
LEFT JOIN rbac.roles r ON i.role_id = r.id
LEFT JOIN users.users inviter ON i.invited_by = inviter.id;
-- ============================================================================
-- FUNCTION: Create invitation
-- ============================================================================
CREATE OR REPLACE FUNCTION teams.create_invitation(
p_tenant_id UUID,
p_email VARCHAR(255),
p_invited_by UUID,
p_role_id UUID DEFAULT NULL,
p_first_name VARCHAR(100) DEFAULT NULL,
p_last_name VARCHAR(100) DEFAULT NULL,
p_department VARCHAR(100) DEFAULT NULL,
p_job_title VARCHAR(100) DEFAULT NULL,
p_personal_message TEXT DEFAULT NULL,
p_expires_in_days INTEGER DEFAULT 7
)
RETURNS TABLE (
invitation_id UUID,
token VARCHAR(64)
) AS $$
DECLARE
v_token VARCHAR(64);
v_token_hash VARCHAR(255);
v_invitation_id UUID;
BEGIN
-- Generate random token
v_token := encode(gen_random_bytes(32), 'hex');
v_token_hash := encode(sha256(v_token::bytea), 'hex');
-- Revoke any existing pending invitations for this email
UPDATE teams.invitations
SET status = 'revoked',
revoked_at = CURRENT_TIMESTAMP,
revoked_by = p_invited_by
WHERE tenant_id = p_tenant_id
AND email = p_email
AND status = 'pending';
-- Create new invitation
INSERT INTO teams.invitations (
tenant_id, email, token_hash, role_id,
first_name, last_name, department, job_title,
personal_message, expires_at, invited_by
) VALUES (
p_tenant_id, p_email, v_token_hash, p_role_id,
p_first_name, p_last_name, p_department, p_job_title,
p_personal_message,
CURRENT_TIMESTAMP + (p_expires_in_days || ' days')::interval,
p_invited_by
)
RETURNING id INTO v_invitation_id;
RETURN QUERY SELECT v_invitation_id, v_token;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- FUNCTION: Accept invitation
-- ============================================================================
CREATE OR REPLACE FUNCTION teams.accept_invitation(
p_token VARCHAR(64),
p_user_id UUID
)
RETURNS TABLE (
success BOOLEAN,
tenant_id UUID,
role_id UUID,
message TEXT
) AS $$
DECLARE
v_invitation teams.invitations%ROWTYPE;
v_token_hash VARCHAR(255);
BEGIN
v_token_hash := encode(sha256(p_token::bytea), 'hex');
-- Find invitation
SELECT * INTO v_invitation
FROM teams.invitations
WHERE token_hash = v_token_hash;
-- Check if invitation exists
IF NOT FOUND THEN
RETURN QUERY SELECT false, NULL::UUID, NULL::UUID, 'Invalid invitation token';
RETURN;
END IF;
-- Check if already processed
IF v_invitation.status != 'pending' THEN
RETURN QUERY SELECT false, NULL::UUID, NULL::UUID,
'Invitation has already been ' || v_invitation.status;
RETURN;
END IF;
-- Check if expired
IF v_invitation.expires_at < CURRENT_TIMESTAMP THEN
UPDATE teams.invitations
SET status = 'expired', updated_at = CURRENT_TIMESTAMP
WHERE id = v_invitation.id;
RETURN QUERY SELECT false, NULL::UUID, NULL::UUID, 'Invitation has expired';
RETURN;
END IF;
-- Accept invitation
UPDATE teams.invitations
SET status = 'accepted',
responded_at = CURRENT_TIMESTAMP,
accepted_user_id = p_user_id,
updated_at = CURRENT_TIMESTAMP
WHERE id = v_invitation.id;
-- Add user to team
PERFORM teams.add_team_member(
v_invitation.tenant_id,
p_user_id,
'member',
v_invitation.invited_by,
v_invitation.id,
v_invitation.department,
v_invitation.job_title
);
-- Assign role if specified
IF v_invitation.role_id IS NOT NULL THEN
PERFORM rbac.assign_role_to_user(
p_user_id,
v_invitation.role_id,
v_invitation.tenant_id,
v_invitation.invited_by,
true -- Make it primary role
);
END IF;
RETURN QUERY SELECT true, v_invitation.tenant_id, v_invitation.role_id,
'Invitation accepted successfully';
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- FUNCTION: Resend invitation
-- ============================================================================
CREATE OR REPLACE FUNCTION teams.resend_invitation(
p_invitation_id UUID,
p_resent_by UUID
)
RETURNS TABLE (
success BOOLEAN,
token VARCHAR(64),
message TEXT
) AS $$
DECLARE
v_invitation teams.invitations%ROWTYPE;
v_new_token VARCHAR(64);
v_new_token_hash VARCHAR(255);
BEGIN
-- Find invitation
SELECT * INTO v_invitation
FROM teams.invitations
WHERE id = p_invitation_id;
IF NOT FOUND THEN
RETURN QUERY SELECT false, NULL::VARCHAR(64), 'Invitation not found';
RETURN;
END IF;
IF v_invitation.status != 'pending' THEN
RETURN QUERY SELECT false, NULL::VARCHAR(64),
'Cannot resend: invitation is ' || v_invitation.status;
RETURN;
END IF;
-- Generate new token
v_new_token := encode(gen_random_bytes(32), 'hex');
v_new_token_hash := encode(sha256(v_new_token::bytea), 'hex');
-- Update invitation
UPDATE teams.invitations
SET token_hash = v_new_token_hash,
resent_count = resent_count + 1,
last_resent_at = CURRENT_TIMESTAMP,
expires_at = CURRENT_TIMESTAMP + INTERVAL '7 days',
updated_at = CURRENT_TIMESTAMP
WHERE id = p_invitation_id;
RETURN QUERY SELECT true, v_new_token, 'Invitation resent successfully';
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- FUNCTION: Expire old invitations (for scheduled job)
-- ============================================================================
CREATE OR REPLACE FUNCTION teams.expire_old_invitations()
RETURNS INTEGER AS $$
DECLARE
v_count INTEGER;
BEGIN
UPDATE teams.invitations
SET status = 'expired', updated_at = CURRENT_TIMESTAMP
WHERE status = 'pending'
AND expires_at < CURRENT_TIMESTAMP;
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- GRANTS
-- ============================================================================
GRANT SELECT, INSERT, UPDATE, DELETE ON teams.invitations TO trading_user;
GRANT SELECT ON teams.v_invitations TO trading_user;
GRANT EXECUTE ON FUNCTION teams.create_invitation TO trading_user;
GRANT EXECUTE ON FUNCTION teams.accept_invitation TO trading_user;
GRANT EXECUTE ON FUNCTION teams.resend_invitation TO trading_user;
GRANT EXECUTE ON FUNCTION teams.expire_old_invitations TO trading_user;
-- ============================================================================
-- COMMENTS
-- ============================================================================
COMMENT ON TABLE teams.invitations IS 'Team/organization invitations for new members';
COMMENT ON COLUMN teams.invitations.token_hash IS 'SHA256 hash of the invitation token';
COMMENT ON COLUMN teams.invitations.role_id IS 'Role to assign when invitation is accepted';
COMMENT ON COLUMN teams.invitations.resent_count IS 'Number of times invitation was resent';
COMMENT ON VIEW teams.v_invitations IS 'Invitations with related details';
COMMENT ON FUNCTION teams.create_invitation IS 'Create a new team invitation';
COMMENT ON FUNCTION teams.accept_invitation IS 'Accept an invitation and join team';