372 lines
12 KiB
PL/PgSQL
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';
|