| 384 | | {{{Triggers}}} |
| 385 | | {{{ |
| 386 | | CREATE OR REPLACE FUNCTION petify_trg_reviews_no_update() |
| 387 | | RETURNS trigger |
| 388 | | LANGUAGE plpgsql |
| 389 | | AS $$ |
| 390 | | BEGIN |
| 391 | | RAISE EXCEPTION 'Reviews are immutable. Updates are not allowed (review_id=%).', OLD.review_id; |
| 392 | | END; |
| 393 | | $$; |
| 394 | | |
| 395 | | DROP TRIGGER IF EXISTS trg_reviews_no_update ON reviews; |
| 396 | | CREATE TRIGGER trg_reviews_no_update |
| 397 | | BEFORE UPDATE |
| 398 | | ON reviews |
| 399 | | FOR EACH ROW |
| 400 | | EXECUTE FUNCTION petify_trg_reviews_no_update(); |
| 401 | | |
| 402 | | DROP TRIGGER IF EXISTS trg_clinic_reviews_no_update ON clinic_reviews; |
| 403 | | CREATE TRIGGER trg_clinic_reviews_no_update |
| 404 | | BEFORE UPDATE |
| 405 | | ON clinic_reviews |
| 406 | | FOR EACH ROW |
| 407 | | EXECUTE FUNCTION petify_trg_no_update_generic(); |
| 408 | | |
| 409 | | CREATE OR REPLACE FUNCTION petify_trg_user_review_exclusive() |
| 410 | | RETURNS trigger |
| 411 | | LANGUAGE plpgsql |
| 412 | | AS $$ |
| 413 | | BEGIN |
| 414 | | IF EXISTS (SELECT 1 FROM clinic_reviews cr WHERE cr.review_id = NEW.review_id) THEN |
| 415 | | RAISE EXCEPTION 'review_id % already used as clinic review (cannot also be user review)', NEW.review_id; |
| 416 | | END IF; |
| 417 | | RETURN NEW; |
| 418 | | END; |
| 419 | | $$; |
| 420 | | |
| 421 | | DROP TRIGGER IF EXISTS trg_user_review_exclusive ON user_reviews; |
| 422 | | CREATE TRIGGER trg_user_review_exclusive |
| 423 | | BEFORE INSERT |
| 424 | | ON user_reviews |
| 425 | | FOR EACH ROW |
| 426 | | EXECUTE FUNCTION petify_trg_user_review_exclusive(); |
| 427 | | |
| 428 | | |
| 429 | | CREATE OR REPLACE FUNCTION petify_trg_clinic_review_exclusive() |
| 430 | | RETURNS trigger |
| 431 | | LANGUAGE plpgsql |
| 432 | | AS $$ |
| 433 | | BEGIN |
| 434 | | IF EXISTS (SELECT 1 FROM user_reviews ur WHERE ur.review_id = NEW.review_id) THEN |
| 435 | | RAISE EXCEPTION 'review_id % already used as user review (cannot also be clinic review)', NEW.review_id; |
| 436 | | END IF; |
| 437 | | RETURN NEW; |
| 438 | | END; |
| 439 | | $$; |
| 440 | | |
| 441 | | DROP TRIGGER IF EXISTS trg_clinic_review_exclusive ON clinic_reviews; |
| 442 | | CREATE TRIGGER trg_clinic_review_exclusive |
| 443 | | BEFORE INSERT |
| 444 | | ON clinic_reviews |
| 445 | | FOR EACH ROW |
| 446 | | EXECUTE FUNCTION petify_trg_clinic_review_exclusive(); |
| 447 | | |
| 448 | | CREATE OR REPLACE FUNCTION petify_trg_user_reviews_cooldown() |
| 449 | | RETURNS trigger |
| 450 | | LANGUAGE plpgsql |
| 451 | | AS $$ |
| 452 | | DECLARE |
| 453 | | v_reviewer bigint; |
| 454 | | v_created timestamp; |
| 455 | | BEGIN |
| 456 | | SELECT reviewer_id, created_at INTO v_reviewer, v_created |
| 457 | | FROM reviews |
| 458 | | WHERE review_id = NEW.review_id; |
| 459 | | |
| 460 | | IF v_reviewer IS NULL THEN |
| 461 | | RAISE EXCEPTION 'Base review % not found', NEW.review_id; |
| 462 | | END IF; |
| 463 | | |
| 464 | | IF v_reviewer = NEW.target_user_id THEN |
| 465 | | RAISE EXCEPTION 'User cannot review themselves (user_id=%)', v_reviewer; |
| 466 | | END IF; |
| 467 | | |
| 468 | | IF EXISTS ( |
| 469 | | SELECT 1 |
| 470 | | FROM user_reviews ur |
| 471 | | JOIN reviews r ON r.review_id = ur.review_id |
| 472 | | WHERE r.reviewer_id = v_reviewer |
| 473 | | AND ur.target_user_id = NEW.target_user_id |
| 474 | | AND r.is_deleted = false |
| 475 | | AND r.created_at >= v_created - interval '30 days' |
| 476 | | ) THEN |
| 477 | | RAISE EXCEPTION 'Cooldown: reviewer % already reviewed user % within last 30 days', |
| 478 | | v_reviewer, NEW.target_user_id; |
| 479 | | END IF; |
| 480 | | |
| 481 | | RETURN NEW; |
| 482 | | END; |
| 483 | | $$; |
| 484 | | |
| 485 | | DROP TRIGGER IF EXISTS trg_user_reviews_cooldown ON user_reviews; |
| 486 | | CREATE TRIGGER trg_user_reviews_cooldown |
| 487 | | BEFORE INSERT |
| 488 | | ON user_reviews |
| 489 | | FOR EACH ROW |
| 490 | | EXECUTE FUNCTION petify_trg_user_reviews_cooldown(); |
| 491 | | |
| 492 | | CREATE OR REPLACE FUNCTION petify_trg_clinic_reviews_cooldown() |
| 493 | | RETURNS trigger |
| 494 | | LANGUAGE plpgsql |
| 495 | | AS $$ |
| 496 | | DECLARE |
| 497 | | v_reviewer bigint; |
| 498 | | v_created timestamp; |
| 499 | | BEGIN |
| 500 | | SELECT reviewer_id, created_at INTO v_reviewer, v_created |
| 501 | | FROM reviews |
| 502 | | WHERE review_id = NEW.review_id; |
| 503 | | |
| 504 | | IF v_reviewer IS NULL THEN |
| 505 | | RAISE EXCEPTION 'Base review % not found', NEW.review_id; |
| 506 | | END IF; |
| 507 | | |
| 508 | | IF EXISTS ( |
| 509 | | SELECT 1 |
| 510 | | FROM clinic_reviews cr |
| 511 | | JOIN reviews r ON r.review_id = cr.review_id |
| 512 | | WHERE r.reviewer_id = v_reviewer |
| 513 | | AND cr.target_clinic_id = NEW.target_clinic_id |
| 514 | | AND r.is_deleted = false |
| 515 | | AND r.created_at >= v_created - interval '30 days' |
| 516 | | ) THEN |
| 517 | | RAISE EXCEPTION 'Cooldown: reviewer % already reviewed clinic % within last 30 days', |
| 518 | | v_reviewer, NEW.target_clinic_id; |
| 519 | | END IF; |
| 520 | | |
| 521 | | RETURN NEW; |
| 522 | | END; |
| 523 | | $$; |
| 524 | | |
| 525 | | DROP TRIGGER IF EXISTS trg_clinic_reviews_cooldown ON clinic_reviews; |
| 526 | | CREATE TRIGGER trg_clinic_reviews_cooldown |
| 527 | | BEFORE INSERT |
| 528 | | ON clinic_reviews |
| 529 | | FOR EACH ROW |
| 530 | | EXECUTE FUNCTION petify_trg_clinic_reviews_cooldown(); |
| 531 | | |
| | 386 | {{{Stored procedures / functions}}} |
| | 387 | {{{ |
| | 388 | CREATE OR REPLACE FUNCTION olpms_is_valid_ticket_transition(p_old text, p_new text) |
| | 389 | RETURNS boolean |
| | 390 | LANGUAGE sql |
| | 391 | AS $$ |
| | 392 | SELECT CASE |
| | 393 | WHEN p_old = p_new THEN true |
| | 394 | WHEN p_old = 'OPEN' AND p_new IN ('IN_PROGRESS', 'RESOLVED') THEN true |
| | 395 | WHEN p_old = 'IN_PROGRESS' AND p_new IN ('RESOLVED') THEN true |
| | 396 | WHEN p_old = 'RESOLVED' AND p_new = 'RESOLVED' THEN true |
| | 397 | ELSE false |
| | 398 | END; |
| | 399 | $$; |
| 537 | | ur.target_user_id, |
| 538 | | COUNT(*) FILTER (WHERE r.is_deleted = false) AS review_count, |
| 539 | | ROUND(AVG(r.rating)::numeric, 2) FILTER (WHERE r.is_deleted = false) AS avg_rating |
| 540 | | FROM user_reviews ur |
| 541 | | JOIN reviews r ON r.review_id = ur.review_id |
| 542 | | GROUP BY ur.target_user_id; |
| 543 | | |
| 544 | | CREATE OR REPLACE VIEW v_clinic_ratings AS |
| 545 | | SELECT |
| 546 | | cr.target_clinic_id, |
| 547 | | COUNT(*) FILTER (WHERE r.is_deleted = false) AS review_count, |
| 548 | | ROUND(AVG(r.rating)::numeric, 2) FILTER (WHERE r.is_deleted = false) AS avg_rating |
| 549 | | FROM clinic_reviews cr |
| 550 | | JOIN reviews r ON r.review_id = cr.review_id |
| 551 | | GROUP BY cr.target_clinic_id; |
| | 405 | st.ticket_id, |
| | 406 | st.subject, |
| | 407 | st.description, |
| | 408 | st.status, |
| | 409 | st.created_at, |
| | 410 | st.user_id, |
| | 411 | u.first_name AS user_first_name, |
| | 412 | u.last_name AS user_last_name, |
| | 413 | u.email AS user_email, |
| | 414 | st.admin_id, |
| | 415 | a.first_name AS admin_first_name, |
| | 416 | a.last_name AS admin_last_name, |
| | 417 | a.email AS admin_email |
| | 418 | FROM support_ticket st |
| | 419 | JOIN user_entity u |
| | 420 | ON u.id = st.user_id |
| | 421 | JOIN user_entity a |
| | 422 | ON a.id = st.admin_id; |
| 555 | | CREATE DOMAIN rating_1_5 AS int CHECK (VALUE BETWEEN 1 AND 5); |
| 556 | | }}} |
| 557 | | == Data constraints requirements: Background Jobs |
| 558 | | === Data requirements description |
| 559 | | These are time-based business rules that must be enforced asynchronously: |
| 560 | | * If an appointment is still CONFIRMED long after its scheduled time, mark it as NO_SHOW. |
| 561 | | * If an Archive listing is older than 30days its status is draft. |
| 562 | | === Implementation |
| 563 | | {{{Stored procedures}}} |
| 564 | | {{{ |
| 565 | | CREATE EXTENSION IF NOT EXISTS pg_cron; |
| 566 | | |
| 567 | | CREATE OR REPLACE PROCEDURE job_mark_no_show() |
| 568 | | LANGUAGE plpgsql |
| 569 | | AS $$ |
| 570 | | BEGIN |
| 571 | | UPDATE appointments |
| 572 | | SET status = 'NO_SHOW' |
| 573 | | WHERE status = 'CONFIRMED' |
| 574 | | AND date_time < now() - interval '45 minutes'; |
| 575 | | END; |
| 576 | | $$; |
| 577 | | |
| 578 | | CREATE OR REPLACE PROCEDURE job_archive_stale_drafts() |
| 579 | | LANGUAGE plpgsql |
| 580 | | AS $$ |
| 581 | | BEGIN |
| 582 | | UPDATE listings |
| 583 | | SET status = 'ARCHIVED' |
| 584 | | WHERE status = 'DRAFT' |
| 585 | | AND created_at < now() - interval '30 days'; |
| 586 | | END; |
| 587 | | $$; |
| 588 | | }}} |
| 589 | | {{{Views}}} |
| 590 | | {{{ |
| 591 | | CREATE OR REPLACE VIEW v_overdue_confirmed_appointments AS |
| 592 | | SELECT * |
| 593 | | FROM appointments |
| 594 | | WHERE status='CONFIRMED' |
| 595 | | AND date_time < now() - interval '45 minutes'; |
| 596 | | |
| 597 | | CREATE OR REPLACE VIEW v_stale_draft_listings AS |
| 598 | | SELECT * |
| 599 | | FROM listings |
| 600 | | WHERE status='DRAFT' |
| 601 | | AND created_at < now() - interval '30 days'; |
| 602 | | }}} |
| 603 | | {{{Scheduling}}} |
| 604 | | {{{ |
| 605 | | SELECT cron.schedule('petify_mark_no_show', '*/10 * * * *', $$CALL job_mark_no_show();$$); |
| 606 | | SELECT cron.schedule('petify_archive_stale_drafts_daily', '10 2 * * *', $$CALL job_archive_stale_drafts();$$); |
| 607 | | }}} |
| | 426 | CREATE DOMAIN ticket_status_domain AS VARCHAR(30) |
| | 427 | CHECK (VALUE IN ('OPEN', 'IN_PROGRESS', 'RESOLVED')); |
| | 428 | |
| | 429 | CREATE DOMAIN non_empty_subject_domain AS VARCHAR(200) |
| | 430 | CHECK (LENGTH(TRIM(VALUE)) > 0); |
| | 431 | }}} |