| 1 | -- =============================================================================
|
|---|
| 2 | -- TRIGGER 1-3: User Subtype Enforcement
|
|---|
| 3 | -- =============================================================================
|
|---|
| 4 | -- A row in Client_User, Vendor_User or Management_User is accepted only when
|
|---|
| 5 | -- "User".type names that subtype. Because the type cannot change afterwards
|
|---|
| 6 | -- (trg_user_type_immutable), no user ever has rows in two subtype tables.
|
|---|
| 7 | CREATE OR REPLACE FUNCTION trg_enforce_user_subtype()
|
|---|
| 8 | RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|---|
| 9 | DECLARE
|
|---|
| 10 | v_expected text;
|
|---|
| 11 | v_actual text;
|
|---|
| 12 | BEGIN
|
|---|
| 13 | v_expected := CASE TG_TABLE_NAME
|
|---|
| 14 | WHEN 'client_user' THEN 'client'
|
|---|
| 15 | WHEN 'vendor_user' THEN 'vendor'
|
|---|
| 16 | WHEN 'management_user' THEN 'management'
|
|---|
| 17 | ELSE NULL
|
|---|
| 18 | END;
|
|---|
| 19 |
|
|---|
| 20 | IF v_expected IS NULL THEN
|
|---|
| 21 | RAISE EXCEPTION 'trg_enforce_user_subtype is not valid on table %', TG_TABLE_NAME;
|
|---|
| 22 | END IF;
|
|---|
| 23 |
|
|---|
| 24 | SELECT type INTO v_actual FROM "User" WHERE user_id = NEW.user_id;
|
|---|
| 25 |
|
|---|
| 26 | IF NOT FOUND THEN
|
|---|
| 27 | RAISE EXCEPTION 'User % does not exist', NEW.user_id;
|
|---|
| 28 | END IF;
|
|---|
| 29 |
|
|---|
| 30 | IF v_actual <> v_expected THEN
|
|---|
| 31 | RAISE EXCEPTION 'User % type must be % (it is %)', NEW.user_id, v_expected, v_actual;
|
|---|
| 32 | END IF;
|
|---|
| 33 |
|
|---|
| 34 | RETURN NEW;
|
|---|
| 35 | END;
|
|---|
| 36 | $$;
|
|---|
| 37 |
|
|---|
| 38 | CREATE OR REPLACE TRIGGER trg_client_user_subtype
|
|---|
| 39 | BEFORE INSERT OR UPDATE ON Client_User
|
|---|
| 40 | FOR EACH ROW EXECUTE FUNCTION trg_enforce_user_subtype();
|
|---|
| 41 |
|
|---|
| 42 | CREATE OR REPLACE TRIGGER trg_vendor_user_subtype
|
|---|
| 43 | BEFORE INSERT OR UPDATE ON Vendor_User
|
|---|
| 44 | FOR EACH ROW EXECUTE FUNCTION trg_enforce_user_subtype();
|
|---|
| 45 |
|
|---|
| 46 | CREATE OR REPLACE TRIGGER trg_management_user_subtype
|
|---|
| 47 | BEFORE INSERT OR UPDATE ON Management_User
|
|---|
| 48 | FOR EACH ROW EXECUTE FUNCTION trg_enforce_user_subtype();
|
|---|
| 49 |
|
|---|
| 50 |
|
|---|
| 51 | -- =============================================================================
|
|---|
| 52 | -- TRIGGER 4: User Type Immutability
|
|---|
| 53 | -- =============================================================================
|
|---|
| 54 | -- Keeps "User".type fixed, so the subtype row written for it cannot become
|
|---|
| 55 | -- inconsistent.
|
|---|
| 56 | CREATE OR REPLACE FUNCTION trg_prevent_user_type_change()
|
|---|
| 57 | RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|---|
| 58 | BEGIN
|
|---|
| 59 | RAISE EXCEPTION 'User % type must stay % and cannot be changed', OLD.user_id, OLD.type;
|
|---|
| 60 | END;
|
|---|
| 61 | $$;
|
|---|
| 62 |
|
|---|
| 63 | CREATE OR REPLACE TRIGGER trg_user_type_immutable
|
|---|
| 64 | BEFORE UPDATE OF type ON "User"
|
|---|
| 65 | FOR EACH ROW
|
|---|
| 66 | WHEN (NEW.type <> OLD.type)
|
|---|
| 67 | EXECUTE FUNCTION trg_prevent_user_type_change();
|
|---|
| 68 |
|
|---|
| 69 |
|
|---|
| 70 | -- =============================================================================
|
|---|
| 71 | -- TRIGGER 5: Vendor Subscription Negotiated Price Validation
|
|---|
| 72 | -- =============================================================================
|
|---|
| 73 | CREATE OR REPLACE FUNCTION trg_enforce_negotiated_price()
|
|---|
| 74 | RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|---|
| 75 | DECLARE
|
|---|
| 76 | v_allows_custom bool;
|
|---|
| 77 | BEGIN
|
|---|
| 78 | PERFORM fn_assert_tier_exists(NEW.tier_id);
|
|---|
| 79 |
|
|---|
| 80 | SELECT allows_custom_pricing
|
|---|
| 81 | INTO v_allows_custom
|
|---|
| 82 | FROM Subscription_Tier
|
|---|
| 83 | WHERE tier_id = NEW.tier_id;
|
|---|
| 84 |
|
|---|
| 85 | IF v_allows_custom AND NEW.negotiated_price IS NULL THEN
|
|---|
| 86 | RAISE EXCEPTION
|
|---|
| 87 | 'negotiated_price is required for tiers with custom pricing (contract_id: %)', NEW.contract_id;
|
|---|
| 88 | END IF;
|
|---|
| 89 |
|
|---|
| 90 | IF NOT v_allows_custom AND NEW.negotiated_price IS NOT NULL THEN
|
|---|
| 91 | RAISE EXCEPTION
|
|---|
| 92 | 'negotiated_price must be NULL for fixed-price tiers (contract_id: %)', NEW.contract_id;
|
|---|
| 93 | END IF;
|
|---|
| 94 |
|
|---|
| 95 | RETURN NEW;
|
|---|
| 96 | END;
|
|---|
| 97 | $$;
|
|---|
| 98 |
|
|---|
| 99 | CREATE OR REPLACE TRIGGER trg_vendor_subscription_pricing
|
|---|
| 100 | BEFORE INSERT OR UPDATE ON Vendor_Subscription
|
|---|
| 101 | FOR EACH ROW EXECUTE FUNCTION trg_enforce_negotiated_price();
|
|---|
| 102 |
|
|---|
| 103 |
|
|---|
| 104 | -- =============================================================================
|
|---|
| 105 | -- TRIGGER 6-12: Automatic updated_at Timestamp Maintenance
|
|---|
| 106 | -- =============================================================================
|
|---|
| 107 | CREATE OR REPLACE FUNCTION trg_set_updated_at()
|
|---|
| 108 | RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|---|
| 109 | BEGIN
|
|---|
| 110 | NEW.updated_at := NOW();
|
|---|
| 111 | RETURN NEW;
|
|---|
| 112 | END;
|
|---|
| 113 | $$;
|
|---|
| 114 |
|
|---|
| 115 | CREATE OR REPLACE TRIGGER trg_project_updated_at
|
|---|
| 116 | BEFORE UPDATE ON Project
|
|---|
| 117 | FOR EACH ROW EXECUTE FUNCTION trg_set_updated_at();
|
|---|
| 118 |
|
|---|
| 119 | CREATE OR REPLACE TRIGGER trg_vendor_subscription_updated_at
|
|---|
| 120 | BEFORE UPDATE ON Vendor_Subscription
|
|---|
| 121 | FOR EACH ROW EXECUTE FUNCTION trg_set_updated_at();
|
|---|
| 122 |
|
|---|
| 123 | CREATE OR REPLACE TRIGGER trg_cvc_updated_at
|
|---|
| 124 | BEFORE UPDATE ON Client_Vendor_Contract
|
|---|
| 125 | FOR EACH ROW EXECUTE FUNCTION trg_set_updated_at();
|
|---|
| 126 |
|
|---|
| 127 | CREATE OR REPLACE TRIGGER trg_pba_updated_at
|
|---|
| 128 | BEFORE UPDATE ON Project_Budget_Audit
|
|---|
| 129 | FOR EACH ROW EXECUTE FUNCTION trg_set_updated_at();
|
|---|
| 130 |
|
|---|
| 131 | CREATE OR REPLACE TRIGGER trg_review_updated_at
|
|---|
| 132 | BEFORE UPDATE ON Review
|
|---|
| 133 | FOR EACH ROW EXECUTE FUNCTION trg_set_updated_at();
|
|---|
| 134 |
|
|---|
| 135 | CREATE OR REPLACE TRIGGER trg_dispute_ticket_updated_at
|
|---|
| 136 | BEFORE UPDATE ON Dispute_Ticket
|
|---|
| 137 | FOR EACH ROW EXECUTE FUNCTION trg_set_updated_at();
|
|---|
| 138 |
|
|---|
| 139 | CREATE OR REPLACE TRIGGER trg_user_updated_at
|
|---|
| 140 | BEFORE UPDATE ON "User"
|
|---|
| 141 | FOR EACH ROW EXECUTE FUNCTION trg_set_updated_at();
|
|---|
| 142 |
|
|---|
| 143 |
|
|---|
| 144 | -- =============================================================================
|
|---|
| 145 | -- TRIGGER 13: Project Budget Change Audit
|
|---|
| 146 | -- =============================================================================
|
|---|
| 147 | CREATE OR REPLACE FUNCTION trg_capture_budget_change()
|
|---|
| 148 | RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|---|
| 149 | BEGIN
|
|---|
| 150 | INSERT INTO Project_Budget_Audit (project_id, old_budget, new_budget)
|
|---|
| 151 | VALUES (OLD.project_id, OLD.budget, NEW.budget);
|
|---|
| 152 | RETURN NEW;
|
|---|
| 153 | END;
|
|---|
| 154 | $$;
|
|---|
| 155 |
|
|---|
| 156 | CREATE OR REPLACE TRIGGER trg_project_budget_audit
|
|---|
| 157 | AFTER UPDATE ON Project
|
|---|
| 158 | FOR EACH ROW
|
|---|
| 159 | WHEN (OLD.budget IS DISTINCT FROM NEW.budget)
|
|---|
| 160 | EXECUTE FUNCTION trg_capture_budget_change();
|
|---|
| 161 |
|
|---|
| 162 |
|
|---|
| 163 | -- =============================================================================
|
|---|
| 164 | -- TRIGGER 14: Project Status History Actor Validation
|
|---|
| 165 | -- =============================================================================
|
|---|
| 166 | CREATE OR REPLACE FUNCTION trg_validate_status_history_actor()
|
|---|
| 167 | RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|---|
| 168 | BEGIN
|
|---|
| 169 | PERFORM fn_assert_project_exists(NEW.project_id);
|
|---|
| 170 |
|
|---|
| 171 | -- A vendor actor must belong to the vendor on the project
|
|---|
| 172 | IF NEW.vendor_user_id IS NOT NULL THEN
|
|---|
| 173 | PERFORM fn_assert_vendor_user_exists(NEW.vendor_user_id);
|
|---|
| 174 |
|
|---|
| 175 | IF NOT coalesce(fn_is_vendor_user_of_project(NEW.vendor_user_id, NEW.project_id), false) THEN
|
|---|
| 176 | RAISE EXCEPTION
|
|---|
| 177 | 'vendor_user % does not belong to the vendor on project %',
|
|---|
| 178 | NEW.vendor_user_id, NEW.project_id;
|
|---|
| 179 | END IF;
|
|---|
| 180 | END IF;
|
|---|
| 181 |
|
|---|
| 182 | IF NEW.management_user_id IS NOT NULL THEN
|
|---|
| 183 | PERFORM fn_assert_management_user_exists(NEW.management_user_id);
|
|---|
| 184 | END IF;
|
|---|
| 185 |
|
|---|
| 186 | RETURN NEW;
|
|---|
| 187 | END;
|
|---|
| 188 | $$;
|
|---|
| 189 |
|
|---|
| 190 | CREATE OR REPLACE TRIGGER trg_status_history_actor
|
|---|
| 191 | BEFORE INSERT ON Project_Status_History
|
|---|
| 192 | FOR EACH ROW EXECUTE FUNCTION trg_validate_status_history_actor();
|
|---|
| 193 |
|
|---|
| 194 |
|
|---|
| 195 | -- =============================================================================
|
|---|
| 196 | -- TRIGGER 15: Vendor Subscription Overlap Prevention
|
|---|
| 197 | -- =============================================================================
|
|---|
| 198 | -- Active and inactive periods alike are checked. end_date is exclusive, so a
|
|---|
| 199 | -- period may start on the day the previous one ends.
|
|---|
| 200 | -- contract_id <> NEW.contract_id excludes the row itself on UPDATE; on INSERT
|
|---|
| 201 | -- the SERIAL default is already assigned when the trigger runs.
|
|---|
| 202 | CREATE OR REPLACE FUNCTION trg_prevent_subscription_overlap()
|
|---|
| 203 | RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|---|
| 204 | BEGIN
|
|---|
| 205 | IF EXISTS (
|
|---|
| 206 | SELECT 1 FROM Vendor_Subscription
|
|---|
| 207 | WHERE vendor_id = NEW.vendor_id
|
|---|
| 208 | AND contract_id <> NEW.contract_id
|
|---|
| 209 | AND (NEW.end_date IS NULL OR start_date < NEW.end_date)
|
|---|
| 210 | AND (end_date IS NULL OR end_date > NEW.start_date)
|
|---|
| 211 | ) THEN
|
|---|
| 212 | RAISE EXCEPTION
|
|---|
| 213 | 'Vendor % already has a subscription period overlapping % - %',
|
|---|
| 214 | NEW.vendor_id, NEW.start_date, coalesce(NEW.end_date::text, 'open');
|
|---|
| 215 | END IF;
|
|---|
| 216 |
|
|---|
| 217 | RETURN NEW;
|
|---|
| 218 | END;
|
|---|
| 219 | $$;
|
|---|
| 220 |
|
|---|
| 221 | CREATE OR REPLACE TRIGGER trg_vendor_subscription_overlap
|
|---|
| 222 | BEFORE INSERT OR UPDATE ON Vendor_Subscription
|
|---|
| 223 | FOR EACH ROW EXECUTE FUNCTION trg_prevent_subscription_overlap();
|
|---|
| 224 |
|
|---|
| 225 |
|
|---|
| 226 | -- =============================================================================
|
|---|
| 227 | -- TRIGGER 16: Dispute Ticket Resolution Validation
|
|---|
| 228 | -- =============================================================================
|
|---|
| 229 | CREATE OR REPLACE FUNCTION trg_validate_dispute_resolution()
|
|---|
| 230 | RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|---|
| 231 | BEGIN
|
|---|
| 232 | IF NEW.is_resolved = true AND OLD.is_resolved = false THEN
|
|---|
| 233 | IF NEW.assigned_management_user_id IS NULL THEN
|
|---|
| 234 | RAISE EXCEPTION
|
|---|
| 235 | 'Dispute ticket % must have an assigned management user before it can be resolved', OLD.ticket_id;
|
|---|
| 236 | END IF;
|
|---|
| 237 |
|
|---|
| 238 | PERFORM fn_assert_management_user_exists(NEW.assigned_management_user_id);
|
|---|
| 239 |
|
|---|
| 240 | IF NEW.resolved_at IS NULL THEN
|
|---|
| 241 | NEW.resolved_at := NOW();
|
|---|
| 242 | END IF;
|
|---|
| 243 | END IF;
|
|---|
| 244 |
|
|---|
| 245 | IF OLD.is_resolved = true AND NEW.is_resolved = false THEN
|
|---|
| 246 | RAISE EXCEPTION 'Resolved dispute ticket % cannot be re-opened', OLD.ticket_id;
|
|---|
| 247 | END IF;
|
|---|
| 248 |
|
|---|
| 249 | RETURN NEW;
|
|---|
| 250 | END;
|
|---|
| 251 | $$;
|
|---|
| 252 |
|
|---|
| 253 | CREATE OR REPLACE TRIGGER trg_dispute_ticket_resolve
|
|---|
| 254 | BEFORE UPDATE ON Dispute_Ticket
|
|---|
| 255 | FOR EACH ROW EXECUTE FUNCTION trg_validate_dispute_resolution();
|
|---|
| 256 |
|
|---|
| 257 |
|
|---|
| 258 | -- =============================================================================
|
|---|
| 259 | -- TRIGGER 17: Disputed Review Publish Guard
|
|---|
| 260 | -- =============================================================================
|
|---|
| 261 | CREATE OR REPLACE FUNCTION trg_prevent_publishing_disputed_review()
|
|---|
| 262 | RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|---|
| 263 | BEGIN
|
|---|
| 264 | IF NEW.is_published = true AND OLD.is_published = false THEN
|
|---|
| 265 | IF EXISTS (
|
|---|
| 266 | SELECT 1 FROM Dispute_Ticket
|
|---|
| 267 | WHERE review_id = NEW.review_id
|
|---|
| 268 | AND is_resolved = false
|
|---|
| 269 | ) THEN
|
|---|
| 270 | RAISE EXCEPTION
|
|---|
| 271 | 'Review % cannot be published while it has unresolved dispute tickets', NEW.review_id;
|
|---|
| 272 | END IF;
|
|---|
| 273 | END IF;
|
|---|
| 274 |
|
|---|
| 275 | RETURN NEW;
|
|---|
| 276 | END;
|
|---|
| 277 | $$;
|
|---|
| 278 |
|
|---|
| 279 | CREATE OR REPLACE TRIGGER trg_review_publish_guard
|
|---|
| 280 | BEFORE UPDATE ON Review
|
|---|
| 281 | FOR EACH ROW EXECUTE FUNCTION trg_prevent_publishing_disputed_review();
|
|---|
| 282 |
|
|---|
| 283 |
|
|---|
| 284 | -- =============================================================================
|
|---|
| 285 | -- TRIGGER 18: Review Only for a Finished Project
|
|---|
| 286 | -- =============================================================================
|
|---|
| 287 | -- A review is the client's verdict on a finished engagement: the project must
|
|---|
| 288 | -- be Completed or Cancelled.
|
|---|
| 289 | CREATE OR REPLACE FUNCTION trg_require_finished_project()
|
|---|
| 290 | RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|---|
| 291 | DECLARE
|
|---|
| 292 | v_status text;
|
|---|
| 293 | BEGIN
|
|---|
| 294 | SELECT ps.status_name
|
|---|
| 295 | INTO v_status
|
|---|
| 296 | FROM Project p
|
|---|
| 297 | JOIN Project_Status ps ON ps.status_id = p.status_id
|
|---|
| 298 | WHERE p.project_id = NEW.project_id;
|
|---|
| 299 |
|
|---|
| 300 | IF NOT FOUND THEN
|
|---|
| 301 | RAISE EXCEPTION 'Project % does not exist', NEW.project_id;
|
|---|
| 302 | END IF;
|
|---|
| 303 |
|
|---|
| 304 | IF v_status NOT IN ('Completed', 'Cancelled') THEN
|
|---|
| 305 | RAISE EXCEPTION 'Project % is %; a review needs a finished project', NEW.project_id, v_status;
|
|---|
| 306 | END IF;
|
|---|
| 307 |
|
|---|
| 308 | RETURN NEW;
|
|---|
| 309 | END;
|
|---|
| 310 | $$;
|
|---|
| 311 |
|
|---|
| 312 | CREATE OR REPLACE TRIGGER trg_review_finished_project
|
|---|
| 313 | BEFORE INSERT OR UPDATE OF project_id ON Review
|
|---|
| 314 | FOR EACH ROW EXECUTE FUNCTION trg_require_finished_project();
|
|---|