DatabaseProgramming: procedures.sql

File procedures.sql, 18.7 KB (added by 235013, 12 days ago)
Line 
1-- =============================================================================
2-- PROCEDURE 1: Register a New User
3-- =============================================================================
4-- p_type is 'client', 'vendor' or 'management' and decides which of
5-- p_client_id, p_vendor_id and p_role_id is required. p_password_hash is
6-- stored as given; hashing is the caller's job. New users start inactive.
7CREATE OR REPLACE PROCEDURE sp_register_user(
8 OUT p_user_id int4,
9 IN p_type text,
10 IN p_first_name text,
11 IN p_last_name text,
12 IN p_email text,
13 IN p_password_hash text,
14 IN p_client_id int4 DEFAULT NULL,
15 IN p_vendor_id int4 DEFAULT NULL,
16 IN p_role_id int4 DEFAULT NULL
17)
18LANGUAGE plpgsql AS $$
19BEGIN
20 CASE p_type
21 WHEN 'client' THEN
22 IF p_client_id IS NULL THEN
23 RAISE EXCEPTION 'p_client_id is required when registering a client user';
24 END IF;
25 PERFORM fn_assert_client_exists(p_client_id);
26 WHEN 'vendor' THEN
27 IF p_vendor_id IS NULL THEN
28 RAISE EXCEPTION 'p_vendor_id is required when registering a vendor user';
29 END IF;
30 PERFORM fn_assert_vendor_exists(p_vendor_id);
31 WHEN 'management' THEN
32 IF p_role_id IS NULL THEN
33 RAISE EXCEPTION 'p_role_id is required when registering a management user';
34 END IF;
35 PERFORM fn_assert_role_exists(p_role_id);
36 ELSE
37 RAISE EXCEPTION 'Invalid user type: %. Must be client, vendor, or management', p_type;
38 END CASE;
39
40 PERFORM fn_assert_not_blank(p_first_name, 'first_name');
41 PERFORM fn_assert_not_blank(p_last_name, 'last_name');
42 PERFORM fn_assert_not_blank(p_email, 'email');
43 PERFORM fn_assert_not_blank(p_password_hash, 'password_hash');
44
45 INSERT INTO "User" (type, first_name, last_name, email, password_hash)
46 VALUES (p_type, p_first_name, p_last_name, p_email, p_password_hash)
47 RETURNING user_id INTO p_user_id;
48
49 CASE p_type
50 WHEN 'client' THEN
51 INSERT INTO Client_User (user_id, client_id)
52 VALUES (p_user_id, p_client_id);
53 WHEN 'vendor' THEN
54 INSERT INTO Vendor_User (user_id, vendor_id)
55 VALUES (p_user_id, p_vendor_id);
56 WHEN 'management' THEN
57 INSERT INTO Management_User (user_id, role_id)
58 VALUES (p_user_id, p_role_id);
59 ELSE
60 RAISE EXCEPTION 'Invalid user type: %. Must be client, vendor, or management', p_type;
61 END CASE;
62END;
63$$;
64
65
66-- =============================================================================
67-- PROCEDURE 2: Activate a User
68-- =============================================================================
69CREATE OR REPLACE PROCEDURE sp_activate_user(
70 IN p_user_id int4
71)
72LANGUAGE plpgsql AS $$
73BEGIN
74 UPDATE "User"
75 SET is_active = true
76 WHERE user_id = p_user_id
77 AND is_active = false;
78
79 IF NOT FOUND THEN
80 RAISE EXCEPTION 'User % does not exist or is already active', p_user_id;
81 END IF;
82END;
83$$;
84
85
86-- =============================================================================
87-- PROCEDURE 3: Deactivate a User
88-- =============================================================================
89CREATE OR REPLACE PROCEDURE sp_deactivate_user(
90 IN p_user_id int4
91)
92LANGUAGE plpgsql AS $$
93DECLARE
94 v_is_management bool;
95BEGIN
96 UPDATE "User"
97 SET is_active = false
98 WHERE user_id = p_user_id
99 AND is_active = true;
100
101 IF NOT FOUND THEN
102 RAISE EXCEPTION 'User % does not exist or is already inactive', p_user_id;
103 END IF;
104
105 -- A management user who leaves must not stay assigned to open tickets
106 SELECT EXISTS (
107 SELECT 1 FROM Management_User WHERE user_id = p_user_id
108 ) INTO v_is_management;
109
110 IF v_is_management THEN
111 UPDATE Dispute_Ticket
112 SET assigned_management_user_id = NULL
113 WHERE assigned_management_user_id = p_user_id
114 AND is_resolved = false;
115 END IF;
116END;
117$$;
118
119
120-- =============================================================================
121-- PROCEDURE 4: Create a Contract with an Initial Project
122-- =============================================================================
123-- The contract (p_cvc_*) and its first project (p_project_*) have separate
124-- start and end dates. p_contract_number is the contract's external
125-- reference and p_total_value the framework value stated in the contract
126-- document. p_currency_code is meant to be an ISO 4217 code; only its
127-- format (three upper-case letters) is checked.
128CREATE OR REPLACE PROCEDURE sp_create_contract_with_project(
129 OUT p_contract_id int4,
130 OUT p_project_id int4,
131 IN p_client_id int4,
132 IN p_vendor_id int4,
133 IN p_contract_title text,
134 IN p_project_name text,
135 IN p_status_id int4,
136 IN p_budget numeric(10,2),
137 IN p_contract_number text DEFAULT NULL,
138 IN p_cvc_start_date date DEFAULT CURRENT_DATE,
139 IN p_cvc_end_date date DEFAULT NULL,
140 IN p_total_value numeric(10,2) DEFAULT NULL,
141 IN p_currency_code text DEFAULT NULL,
142 IN p_terms_summary text DEFAULT NULL,
143 IN p_project_start_date date DEFAULT CURRENT_DATE,
144 IN p_project_end_date date DEFAULT NULL
145)
146LANGUAGE plpgsql AS $$
147BEGIN
148 PERFORM fn_assert_not_blank(p_contract_title, 'contract_title');
149 PERFORM fn_assert_not_blank(p_project_name, 'project_name');
150
151 PERFORM fn_assert_positive(p_budget, 'Budget');
152
153 PERFORM fn_assert_client_exists(p_client_id);
154 PERFORM fn_assert_vendor_exists(p_vendor_id);
155 PERFORM fn_assert_status_exists(p_status_id);
156
157 INSERT INTO Client_Vendor_Contract (
158 client_id, vendor_id, contract_title, contract_number,
159 start_date, end_date, total_value, currency_code, terms_summary
160 )
161 VALUES (
162 p_client_id, p_vendor_id, p_contract_title, p_contract_number,
163 p_cvc_start_date, p_cvc_end_date, p_total_value, p_currency_code, p_terms_summary
164 )
165 RETURNING contract_id INTO p_contract_id;
166
167 INSERT INTO Project (
168 contract_id, status_id, project_name, start_date, end_date, budget
169 )
170 VALUES (
171 p_contract_id, p_status_id, p_project_name,
172 p_project_start_date, p_project_end_date, p_budget
173 )
174 RETURNING project_id INTO p_project_id;
175END;
176$$;
177
178
179-- =============================================================================
180-- PROCEDURE 5: Update Project Status
181-- =============================================================================
182-- The change is recorded in Project_Status_History with exactly one actor:
183-- p_vendor_user_id or p_management_user_id. An unchanged status returns
184-- without adding history.
185CREATE OR REPLACE PROCEDURE sp_update_project_status(
186 IN p_project_id int4,
187 IN p_new_status_id int4,
188 IN p_vendor_user_id int4 DEFAULT NULL,
189 IN p_management_user_id int4 DEFAULT NULL,
190 IN p_comment text DEFAULT NULL
191)
192LANGUAGE plpgsql AS $$
193DECLARE
194 v_current_status_id int4;
195BEGIN
196 -- Exactly one actor: a vendor user or a management user, never both or neither
197 IF (p_vendor_user_id IS NULL) = (p_management_user_id IS NULL) THEN
198 RAISE EXCEPTION
199 'Provide exactly one actor: p_vendor_user_id or p_management_user_id';
200 END IF;
201
202 PERFORM fn_assert_status_exists(p_new_status_id);
203
204 SELECT status_id INTO v_current_status_id
205 FROM Project
206 WHERE project_id = p_project_id;
207
208 IF v_current_status_id = p_new_status_id THEN
209 RAISE NOTICE 'Project % is already at status % – no update performed',
210 p_project_id, p_new_status_id;
211 RETURN;
212 END IF;
213
214 -- A reviewed project stays finished: this procedure never returns it to an
215 -- open status
216 IF EXISTS (SELECT 1 FROM Review WHERE project_id = p_project_id)
217 AND (SELECT status_name FROM Project_Status WHERE status_id = p_new_status_id)
218 NOT IN ('Completed', 'Cancelled') THEN
219 RAISE EXCEPTION 'Project % has a review and cannot return to an open status', p_project_id;
220 END IF;
221
222 UPDATE Project
223 SET status_id = p_new_status_id
224 WHERE project_id = p_project_id;
225
226 -- The project, the actor and the actor's vendor are checked by trg_status_history_actor
227 INSERT INTO Project_Status_History (
228 project_id, status_id, vendor_user_id, management_user_id, comment
229 )
230 VALUES (
231 p_project_id, p_new_status_id, p_vendor_user_id, p_management_user_id, p_comment
232 );
233END;
234$$;
235
236
237-- =============================================================================
238-- PROCEDURE 6: Update Project Budget
239-- =============================================================================
240CREATE OR REPLACE PROCEDURE sp_update_project_budget(
241 IN p_project_id int4,
242 IN p_new_budget numeric(10,2)
243)
244LANGUAGE plpgsql AS $$
245BEGIN
246 PERFORM fn_assert_positive(p_new_budget, 'Budget');
247
248 -- trg_project_budget_audit records the old and new budget in
249 -- Project_Budget_Audit when the value changes; an unchanged value adds no
250 -- audit row
251 UPDATE Project
252 SET budget = p_new_budget
253 WHERE project_id = p_project_id;
254
255 IF NOT FOUND THEN
256 PERFORM fn_assert_project_exists(p_project_id);
257 END IF;
258END;
259$$;
260
261
262-- =============================================================================
263-- PROCEDURE 7: Submit a Review with Scores
264-- =============================================================================
265-- p_scores is a non-empty JSON array of {dimension_id, score_value} objects
266-- with distinct, existing dimensions and scores from 1 to 5. A project gets
267-- at most one review, written by a user of its client.
268CREATE OR REPLACE PROCEDURE sp_submit_review(
269 OUT p_review_id int4,
270 IN p_project_id int4,
271 IN p_client_user_id int4,
272 IN p_summary_text text,
273 IN p_scores jsonb
274)
275LANGUAGE plpgsql AS $$
276DECLARE
277 v_dimension_id int4;
278 v_score_value int4;
279BEGIN
280 PERFORM fn_assert_not_blank(p_summary_text, 'summary_text');
281
282 -- Shape of p_scores: a non-empty array of objects with a JSON number as
283 -- dimension_id and score_value; the casts to int4 come later
284 IF p_scores IS NULL OR jsonb_typeof(p_scores) <> 'array' THEN
285 RAISE EXCEPTION 'p_scores must be a JSON array of {dimension_id, score_value} objects';
286 END IF;
287
288 IF jsonb_array_length(p_scores) = 0 THEN
289 RAISE EXCEPTION 'p_scores must contain at least one dimension score';
290 END IF;
291
292 IF EXISTS (
293 SELECT 1
294 FROM jsonb_array_elements(p_scores) AS t(v)
295 WHERE jsonb_typeof(v) <> 'object'
296 OR jsonb_typeof(v->'dimension_id') IS DISTINCT FROM 'number'
297 OR jsonb_typeof(v->'score_value') IS DISTINCT FROM 'number'
298 ) THEN
299 RAISE EXCEPTION
300 'Each element of p_scores must be an object with numeric dimension_id and score_value';
301 END IF;
302
303 PERFORM fn_assert_project_exists(p_project_id);
304 PERFORM fn_assert_client_user_exists(p_client_user_id);
305
306 -- The client user must belong to the client of the project; NULL is a
307 -- mismatch
308 IF NOT coalesce(fn_is_client_user_of_project(p_client_user_id, p_project_id), false) THEN
309 RAISE EXCEPTION
310 'Client user % does not belong to the client associated with project %',
311 p_client_user_id, p_project_id;
312 END IF;
313
314 IF EXISTS (SELECT 1 FROM Review WHERE project_id = p_project_id) THEN
315 RAISE EXCEPTION 'A review already exists for project %', p_project_id;
316 END IF;
317
318 -- Duplicate dimensions
319 IF EXISTS (
320 SELECT 1
321 FROM (
322 SELECT (v->>'dimension_id')::int4 AS dim_id
323 FROM jsonb_array_elements(p_scores) AS t(v)
324 ) dims
325 GROUP BY dim_id
326 HAVING COUNT(*) > 1
327 ) THEN
328 RAISE EXCEPTION 'Duplicate dimension_id found in p_scores';
329 END IF;
330
331 -- Every dimension must exist
332 SELECT (v->>'dimension_id')::int4
333 INTO v_dimension_id
334 FROM jsonb_array_elements(p_scores) AS t(v)
335 WHERE NOT EXISTS (
336 SELECT 1 FROM Rating_Dimension rd
337 WHERE rd.dimension_id = (v->>'dimension_id')::int4
338 )
339 LIMIT 1;
340
341 IF FOUND THEN
342 PERFORM fn_assert_dimension_exists(v_dimension_id);
343 END IF;
344
345 -- Every score must be within 1..5
346 SELECT (v->>'score_value')::int4
347 INTO v_score_value
348 FROM jsonb_array_elements(p_scores) AS t(v)
349 WHERE (v->>'score_value')::int4 NOT BETWEEN 1 AND 5
350 LIMIT 1;
351
352 IF FOUND THEN
353 RAISE EXCEPTION 'Score value must be between 1 and 5 (received: %)', v_score_value;
354 END IF;
355
356 -- That the project is finished (Completed or Cancelled) is checked by trg_review_finished_project
357 INSERT INTO Review (project_id, client_user_id, summary_text)
358 VALUES (p_project_id, p_client_user_id, p_summary_text)
359 RETURNING review_id INTO p_review_id;
360
361 INSERT INTO Review_Score (review_id, dimension_id, score_value)
362 SELECT p_review_id, (v->>'dimension_id')::int4, (v->>'score_value')::int4
363 FROM jsonb_array_elements(p_scores) AS t(v);
364END;
365$$;
366
367
368-- =============================================================================
369-- PROCEDURE 8: File a Dispute Ticket
370-- =============================================================================
371-- The dispute is filed by a user of the vendor that delivered the reviewed
372-- project.
373CREATE OR REPLACE PROCEDURE sp_file_dispute(
374 OUT p_ticket_id int4,
375 IN p_review_id int4,
376 IN p_vendor_user_id int4,
377 IN p_reason text
378)
379LANGUAGE plpgsql AS $$
380DECLARE
381 v_project_id int4;
382BEGIN
383 PERFORM fn_assert_not_blank(p_reason, 'reason');
384
385 PERFORM fn_assert_review_exists(p_review_id);
386 PERFORM fn_assert_vendor_user_exists(p_vendor_user_id);
387
388 -- At most one unresolved dispute per vendor user and review
389 IF EXISTS (
390 SELECT 1 FROM Dispute_Ticket
391 WHERE review_id = p_review_id
392 AND vendor_user_id = p_vendor_user_id
393 AND is_resolved = false
394 ) THEN
395 RAISE EXCEPTION
396 'Vendor user % already has an unresolved dispute on review %',
397 p_vendor_user_id, p_review_id;
398 END IF;
399
400 -- The vendor user must belong to the vendor of the reviewed project; NULL
401 -- is a mismatch
402 SELECT project_id
403 INTO v_project_id
404 FROM Review
405 WHERE review_id = p_review_id;
406
407 IF NOT coalesce(fn_is_vendor_user_of_project(p_vendor_user_id, v_project_id), false) THEN
408 RAISE EXCEPTION
409 'Vendor user % does not belong to the vendor associated with review %',
410 p_vendor_user_id, p_review_id;
411 END IF;
412
413 INSERT INTO Dispute_Ticket (review_id, vendor_user_id, reason)
414 VALUES (p_review_id, p_vendor_user_id, p_reason)
415 RETURNING ticket_id INTO p_ticket_id;
416END;
417$$;
418
419
420-- =============================================================================
421-- PROCEDURE 9: Resolve a Dispute Ticket
422-- =============================================================================
423CREATE OR REPLACE PROCEDURE sp_resolve_dispute(
424 IN p_ticket_id int4,
425 IN p_assigned_management_user_id int4,
426 IN p_resolution_note text
427)
428LANGUAGE plpgsql AS $$
429BEGIN
430 PERFORM fn_assert_not_blank(p_resolution_note, 'resolution_note');
431
432 -- The assigned management user is checked by trg_dispute_ticket_resolve
433 -- on the UPDATE below, which also fills resolved_at
434 UPDATE Dispute_Ticket
435 SET assigned_management_user_id = p_assigned_management_user_id,
436 resolution_note = p_resolution_note,
437 is_resolved = true
438 WHERE ticket_id = p_ticket_id
439 AND is_resolved = false;
440
441 IF NOT FOUND THEN
442 RAISE EXCEPTION
443 'Ticket % does not exist or is already resolved', p_ticket_id;
444 END IF;
445END;
446$$;
447
448
449-- =============================================================================
450-- PROCEDURE 10: Publish a Review
451-- =============================================================================
452CREATE OR REPLACE PROCEDURE sp_publish_review(
453 IN p_review_id int4
454)
455LANGUAGE plpgsql AS $$
456BEGIN
457 -- trg_review_publish_guard refuses the UPDATE while the review has an
458 -- unresolved dispute
459 UPDATE Review
460 SET is_published = true
461 WHERE review_id = p_review_id
462 AND is_published = false;
463
464 IF NOT FOUND THEN
465 RAISE EXCEPTION
466 'Review % does not exist or is already published', p_review_id;
467 END IF;
468END;
469$$;
470
471
472-- =============================================================================
473-- PROCEDURE 11: Renew a Vendor Subscription
474-- =============================================================================
475-- p_new_negotiated_price is required for tiers with custom pricing and must
476-- be NULL for fixed-price tiers. p_new_contract_id is the Vendor_Subscription
477-- key of the new period.
478CREATE OR REPLACE PROCEDURE sp_renew_vendor_subscription(
479 OUT p_new_contract_id int4,
480 IN p_vendor_id int4,
481 IN p_new_tier_id int4,
482 IN p_new_start_date date DEFAULT CURRENT_DATE,
483 IN p_new_end_date date DEFAULT NULL,
484 IN p_new_negotiated_price numeric(10,2) DEFAULT NULL
485)
486LANGUAGE plpgsql AS $$
487DECLARE
488 v_current_start date;
489BEGIN
490 PERFORM fn_assert_vendor_exists(p_vendor_id);
491
492 IF p_new_start_date IS NULL THEN
493 RAISE EXCEPTION 'start_date is required';
494 END IF;
495
496 IF p_new_end_date IS NOT NULL AND p_new_end_date <= p_new_start_date THEN
497 RAISE EXCEPTION
498 'end_date (%) must be after start_date (%)', p_new_end_date, p_new_start_date;
499 END IF;
500
501 -- The current period is closed at the renewal start (end_date is
502 -- exclusive; an earlier end date is kept), so the new period must start
503 -- after the current one began, or closing it would leave an empty period
504 SELECT start_date
505 INTO v_current_start
506 FROM Vendor_Subscription
507 WHERE vendor_id = p_vendor_id
508 AND is_active = true;
509
510 IF v_current_start IS NOT NULL AND v_current_start >= p_new_start_date THEN
511 RAISE EXCEPTION
512 'The current period of vendor % began on %; the new period must start after that',
513 p_vendor_id, v_current_start;
514 END IF;
515
516 UPDATE Vendor_Subscription
517 SET is_active = false,
518 end_date = LEAST(end_date, p_new_start_date)
519 WHERE vendor_id = p_vendor_id
520 AND is_active = true;
521
522 -- The tier and the negotiated price are checked by trg_vendor_subscription_pricing
523 INSERT INTO Vendor_Subscription (
524 vendor_id, tier_id, negotiated_price, start_date, end_date
525 )
526 VALUES (
527 p_vendor_id, p_new_tier_id, p_new_negotiated_price,
528 p_new_start_date, p_new_end_date
529 )
530 RETURNING contract_id INTO p_new_contract_id;
531END;
532$$;