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