]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/sql/Pg/090.schema.action.sql
LP#1549505: update baseline database schema
[Evergreen.git] / Open-ILS / src / sql / Pg / 090.schema.action.sql
1 /*
2  * Copyright (C) 2004-2008  Georgia Public Library Service
3  * Copyright (C) 2007-2008  Equinox Software, Inc.
4  * Mike Rylander <miker@esilibrary.com> 
5  *
6  * This program is free software; you can redistribute it and/or
7  * modify it under the terms of the GNU General Public License
8  * as published by the Free Software Foundation; either version 2
9  * of the License, or (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14  * GNU General Public License for more details.
15  *
16  */
17
18 DROP SCHEMA IF EXISTS action CASCADE;
19
20 BEGIN;
21
22 CREATE SCHEMA action;
23
24 CREATE TABLE action.in_house_use (
25         id              SERIAL                          PRIMARY KEY,
26         item            BIGINT                          NOT NULL, -- REFERENCES asset.copy (id) DEFERRABLE INITIALLY DEFERRED, -- XXX could be an serial.issuance
27         staff           INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
28         org_unit        INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
29         use_time        TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW()
30 );
31 CREATE INDEX action_in_house_use_staff_idx      ON action.in_house_use ( staff );
32
33 CREATE TABLE action.non_cataloged_circulation (
34         id              SERIAL                          PRIMARY KEY,
35         patron          INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
36         staff           INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
37         circ_lib        INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
38         item_type       INT                             NOT NULL REFERENCES config.non_cataloged_type (id) DEFERRABLE INITIALLY DEFERRED,
39         circ_time       TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW()
40 );
41 CREATE INDEX action_non_cat_circ_patron_idx ON action.non_cataloged_circulation ( patron );
42 CREATE INDEX action_non_cat_circ_staff_idx  ON action.non_cataloged_circulation ( staff );
43
44 CREATE TABLE action.non_cat_in_house_use (
45         id              SERIAL                          PRIMARY KEY,
46         item_type       BIGINT                          NOT NULL REFERENCES config.non_cataloged_type(id) DEFERRABLE INITIALLY DEFERRED,
47         staff           INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
48         org_unit        INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
49         use_time        TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW()
50 );
51 CREATE INDEX non_cat_in_house_use_staff_idx ON action.non_cat_in_house_use ( staff );
52
53 CREATE TABLE action.survey (
54         id              SERIAL                          PRIMARY KEY,
55         owner           INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
56         start_date      TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW(),
57         end_date        TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW() + '10 years'::INTERVAL,
58         usr_summary     BOOL                            NOT NULL DEFAULT FALSE,
59         opac            BOOL                            NOT NULL DEFAULT FALSE,
60         poll            BOOL                            NOT NULL DEFAULT FALSE,
61         required        BOOL                            NOT NULL DEFAULT FALSE,
62         name            TEXT                            NOT NULL,
63         description     TEXT                            NOT NULL
64 );
65 CREATE UNIQUE INDEX asv_once_per_owner_idx ON action.survey (owner,name);
66
67 CREATE TABLE action.survey_question (
68         id              SERIAL  PRIMARY KEY,
69         survey          INT     NOT NULL REFERENCES action.survey DEFERRABLE INITIALLY DEFERRED,
70         question        TEXT    NOT NULL
71 );
72
73 CREATE TABLE action.survey_answer (
74         id              SERIAL  PRIMARY KEY,
75         question        INT     NOT NULL REFERENCES action.survey_question DEFERRABLE INITIALLY DEFERRED,
76         answer          TEXT    NOT NULL
77 );
78
79 CREATE SEQUENCE action.survey_response_group_id_seq;
80
81 CREATE TABLE action.survey_response (
82         id                      BIGSERIAL                       PRIMARY KEY,
83         response_group_id       INT,
84         usr                     INT, -- REFERENCES actor.usr
85         survey                  INT                             NOT NULL REFERENCES action.survey DEFERRABLE INITIALLY DEFERRED,
86         question                INT                             NOT NULL REFERENCES action.survey_question DEFERRABLE INITIALLY DEFERRED,
87         answer                  INT                             NOT NULL REFERENCES action.survey_answer DEFERRABLE INITIALLY DEFERRED,
88         answer_date             TIMESTAMP WITH TIME ZONE,
89         effective_date          TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW()
90 );
91 CREATE INDEX action_survey_response_usr_idx ON action.survey_response ( usr );
92
93 CREATE OR REPLACE FUNCTION action.survey_response_answer_date_fixup () RETURNS TRIGGER AS '
94 BEGIN
95         NEW.answer_date := NOW();
96         RETURN NEW;
97 END;
98 ' LANGUAGE 'plpgsql';
99 CREATE TRIGGER action_survey_response_answer_date_fixup_tgr
100         BEFORE INSERT ON action.survey_response
101         FOR EACH ROW
102         EXECUTE PROCEDURE action.survey_response_answer_date_fixup ();
103
104 CREATE TABLE action.archive_actor_stat_cat (
105     id          BIGSERIAL   PRIMARY KEY,
106     xact        BIGINT      NOT NULL, -- action.circulation (+aged/all)
107     stat_cat    INT         NOT NULL,
108     value       TEXT        NOT NULL
109 );
110
111 CREATE TABLE action.archive_asset_stat_cat (
112     id          BIGSERIAL   PRIMARY KEY,
113     xact        BIGINT      NOT NULL, -- action.circulation (+aged/all)
114     stat_cat    INT         NOT NULL,
115     value       TEXT        NOT NULL
116 );
117
118
119 CREATE TABLE action.circulation (
120         target_copy             BIGINT                          NOT NULL, -- asset.copy.id
121         circ_lib                INT                             NOT NULL, -- actor.org_unit.id
122         circ_staff              INT                             NOT NULL, -- actor.usr.id
123         checkin_staff           INT,                                      -- actor.usr.id
124         checkin_lib             INT,                                      -- actor.org_unit.id
125         renewal_remaining       INT                             NOT NULL, -- derived from "circ duration" rule
126     grace_period           INTERVAL             NOT NULL, -- derived from "circ fine" rule
127         due_date                TIMESTAMP WITH TIME ZONE,
128         stop_fines_time         TIMESTAMP WITH TIME ZONE,
129         checkin_time            TIMESTAMP WITH TIME ZONE,
130         create_time             TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
131         duration                INTERVAL,                                 -- derived from "circ duration" rule
132         fine_interval           INTERVAL                        NOT NULL DEFAULT '1 day'::INTERVAL, -- derived from "circ fine" rule
133         recurring_fine          NUMERIC(6,2),                             -- derived from "circ fine" rule
134         max_fine                NUMERIC(6,2),                             -- derived from "max fine" rule
135         phone_renewal           BOOL                            NOT NULL DEFAULT FALSE,
136         desk_renewal            BOOL                            NOT NULL DEFAULT FALSE,
137         opac_renewal            BOOL                            NOT NULL DEFAULT FALSE,
138         duration_rule           TEXT                            NOT NULL, -- name of "circ duration" rule
139         recurring_fine_rule     TEXT                            NOT NULL, -- name of "circ fine" rule
140         max_fine_rule           TEXT                            NOT NULL, -- name of "max fine" rule
141         stop_fines              TEXT                            CHECK (stop_fines IN (
142                                                'CHECKIN','CLAIMSRETURNED','LOST','MAXFINES','RENEW','LONGOVERDUE','CLAIMSNEVERCHECKEDOUT')),
143         workstation         INT        REFERENCES actor.workstation(id)
144                                        ON DELETE SET NULL
145                                                                    DEFERRABLE INITIALLY DEFERRED,
146         checkin_workstation INT        REFERENCES actor.workstation(id)
147                                        ON DELETE SET NULL
148                                                                    DEFERRABLE INITIALLY DEFERRED,
149         copy_location   INT                             NOT NULL DEFAULT 1 REFERENCES asset.copy_location (id) DEFERRABLE INITIALLY DEFERRED,
150         checkin_scan_time   TIMESTAMP WITH TIME ZONE
151 ) INHERITS (money.billable_xact);
152 ALTER TABLE action.circulation ADD PRIMARY KEY (id);
153 ALTER TABLE action.circulation
154         ADD COLUMN parent_circ BIGINT
155         REFERENCES action.circulation( id )
156         DEFERRABLE INITIALLY DEFERRED;
157 CREATE INDEX circ_open_xacts_idx ON action.circulation (usr) WHERE xact_finish IS NULL;
158 CREATE INDEX circ_outstanding_idx ON action.circulation (usr) WHERE checkin_time IS NULL;
159 CREATE INDEX circ_checkin_time ON "action".circulation (checkin_time) WHERE checkin_time IS NOT NULL;
160 CREATE INDEX circ_circ_lib_idx ON "action".circulation (circ_lib);
161 CREATE INDEX circ_open_date_idx ON "action".circulation (xact_start) WHERE xact_finish IS NULL;
162 CREATE INDEX circ_all_usr_idx       ON action.circulation ( usr );
163 CREATE INDEX circ_circ_staff_idx    ON action.circulation ( circ_staff );
164 CREATE INDEX circ_checkin_staff_idx ON action.circulation ( checkin_staff );
165 CREATE INDEX action_circulation_target_copy_idx ON action.circulation (target_copy);
166 CREATE UNIQUE INDEX circ_parent_idx ON action.circulation ( parent_circ ) WHERE parent_circ IS NOT NULL;
167 CREATE UNIQUE INDEX only_one_concurrent_checkout_per_copy ON action.circulation(target_copy) WHERE checkin_time IS NULL;
168
169 CREATE TRIGGER action_circulation_target_copy_trig AFTER INSERT OR UPDATE ON action.circulation FOR EACH ROW EXECUTE PROCEDURE evergreen.fake_fkey_tgr('target_copy');
170
171 CREATE TRIGGER mat_summary_create_tgr AFTER INSERT ON action.circulation FOR EACH ROW EXECUTE PROCEDURE money.mat_summary_create ('circulation');
172 CREATE TRIGGER mat_summary_change_tgr AFTER UPDATE ON action.circulation FOR EACH ROW EXECUTE PROCEDURE money.mat_summary_update ();
173 CREATE TRIGGER mat_summary_remove_tgr AFTER DELETE ON action.circulation FOR EACH ROW EXECUTE PROCEDURE money.mat_summary_delete ();
174
175 CREATE OR REPLACE FUNCTION action.push_circ_due_time () RETURNS TRIGGER AS $$
176 BEGIN
177     IF (EXTRACT(EPOCH FROM NEW.duration)::INT % EXTRACT(EPOCH FROM '1 day'::INTERVAL)::INT) = 0 THEN
178         NEW.due_date = (NEW.due_date::DATE + '1 day'::INTERVAL - '1 second'::INTERVAL)::TIMESTAMPTZ;
179     END IF;
180
181     RETURN NEW;
182 END;
183 $$ LANGUAGE PLPGSQL;
184
185 CREATE TRIGGER push_due_date_tgr BEFORE INSERT OR UPDATE ON action.circulation FOR EACH ROW EXECUTE PROCEDURE action.push_circ_due_time();
186
187 CREATE OR REPLACE FUNCTION action.fill_circ_copy_location () RETURNS TRIGGER AS $$
188 BEGIN
189     SELECT INTO NEW.copy_location location FROM asset.copy WHERE id = NEW.target_copy;
190     RETURN NEW;
191 END;
192 $$ LANGUAGE PLPGSQL;
193
194 CREATE TRIGGER fill_circ_copy_location_tgr BEFORE INSERT ON action.circulation FOR EACH ROW EXECUTE PROCEDURE action.fill_circ_copy_location();
195
196 CREATE OR REPLACE FUNCTION action.archive_stat_cats () RETURNS TRIGGER AS $$
197 BEGIN
198     INSERT INTO action.archive_actor_stat_cat(xact, stat_cat, value)
199         SELECT NEW.id, asceum.stat_cat, asceum.stat_cat_entry
200         FROM actor.stat_cat_entry_usr_map asceum
201              JOIN actor.stat_cat sc ON asceum.stat_cat = sc.id
202         WHERE NEW.usr = asceum.target_usr AND sc.checkout_archive;
203     INSERT INTO action.archive_asset_stat_cat(xact, stat_cat, value)
204         SELECT NEW.id, ascecm.stat_cat, asce.value
205         FROM asset.stat_cat_entry_copy_map ascecm
206              JOIN asset.stat_cat sc ON ascecm.stat_cat = sc.id
207              JOIN asset.stat_cat_entry asce ON ascecm.stat_cat_entry = asce.id
208         WHERE NEW.target_copy = ascecm.owning_copy AND sc.checkout_archive;
209     RETURN NULL;
210 END;
211 $$ LANGUAGE PLPGSQL;
212
213 CREATE TRIGGER archive_stat_cats_tgr AFTER INSERT ON action.circulation FOR EACH ROW EXECUTE PROCEDURE action.archive_stat_cats();
214
215 CREATE TABLE action.aged_circulation (
216         usr_post_code           TEXT,
217         usr_home_ou             INT     NOT NULL,
218         usr_profile             INT     NOT NULL,
219         usr_birth_year          INT,
220         copy_call_number        INT     NOT NULL,
221         copy_owning_lib         INT     NOT NULL,
222         copy_circ_lib           INT     NOT NULL,
223         copy_bib_record         BIGINT  NOT NULL,
224         LIKE action.circulation
225
226 );
227 ALTER TABLE action.aged_circulation ADD PRIMARY KEY (id);
228 ALTER TABLE action.aged_circulation DROP COLUMN usr;
229 CREATE INDEX aged_circ_circ_lib_idx ON "action".aged_circulation (circ_lib);
230 CREATE INDEX aged_circ_start_idx ON "action".aged_circulation (xact_start);
231 CREATE INDEX aged_circ_copy_circ_lib_idx ON "action".aged_circulation (copy_circ_lib);
232 CREATE INDEX aged_circ_copy_owning_lib_idx ON "action".aged_circulation (copy_owning_lib);
233 CREATE INDEX aged_circ_copy_location_idx ON "action".aged_circulation (copy_location);
234 CREATE INDEX action_aged_circulation_target_copy_idx ON action.aged_circulation (target_copy);
235
236 CREATE OR REPLACE VIEW action.all_circulation AS
237     SELECT  id,usr_post_code, usr_home_ou, usr_profile, usr_birth_year, copy_call_number, copy_location,
238         copy_owning_lib, copy_circ_lib, copy_bib_record, xact_start, xact_finish, target_copy,
239         circ_lib, circ_staff, checkin_staff, checkin_lib, renewal_remaining, grace_period, due_date,
240         stop_fines_time, checkin_time, create_time, duration, fine_interval, recurring_fine,
241         max_fine, phone_renewal, desk_renewal, opac_renewal, duration_rule, recurring_fine_rule,
242         max_fine_rule, stop_fines, workstation, checkin_workstation, checkin_scan_time, parent_circ
243       FROM  action.aged_circulation
244             UNION ALL
245     SELECT  DISTINCT circ.id,COALESCE(a.post_code,b.post_code) AS usr_post_code, p.home_ou AS usr_home_ou, p.profile AS usr_profile, EXTRACT(YEAR FROM p.dob)::INT AS usr_birth_year,
246         cp.call_number AS copy_call_number, circ.copy_location, cn.owning_lib AS copy_owning_lib, cp.circ_lib AS copy_circ_lib,
247         cn.record AS copy_bib_record, circ.xact_start, circ.xact_finish, circ.target_copy, circ.circ_lib, circ.circ_staff, circ.checkin_staff,
248         circ.checkin_lib, circ.renewal_remaining, circ.grace_period, circ.due_date, circ.stop_fines_time, circ.checkin_time, circ.create_time, circ.duration,
249         circ.fine_interval, circ.recurring_fine, circ.max_fine, circ.phone_renewal, circ.desk_renewal, circ.opac_renewal, circ.duration_rule,
250         circ.recurring_fine_rule, circ.max_fine_rule, circ.stop_fines, circ.workstation, circ.checkin_workstation, circ.checkin_scan_time,
251         circ.parent_circ
252       FROM  action.circulation circ
253         JOIN asset.copy cp ON (circ.target_copy = cp.id)
254         JOIN asset.call_number cn ON (cp.call_number = cn.id)
255         JOIN actor.usr p ON (circ.usr = p.id)
256         LEFT JOIN actor.usr_address a ON (p.mailing_address = a.id)
257         LEFT JOIN actor.usr_address b ON (p.billing_address = b.id);
258
259 CREATE OR REPLACE FUNCTION action.age_circ_on_delete () RETURNS TRIGGER AS $$
260 DECLARE
261 found char := 'N';
262 BEGIN
263
264     -- If there are any renewals for this circulation, don't archive or delete
265     -- it yet.   We'll do so later, when we archive and delete the renewals.
266
267     SELECT 'Y' INTO found
268     FROM action.circulation
269     WHERE parent_circ = OLD.id
270     LIMIT 1;
271
272     IF found = 'Y' THEN
273         RETURN NULL;  -- don't delete
274         END IF;
275
276     -- Archive a copy of the old row to action.aged_circulation
277
278     INSERT INTO action.aged_circulation
279         (id,usr_post_code, usr_home_ou, usr_profile, usr_birth_year, copy_call_number, copy_location,
280         copy_owning_lib, copy_circ_lib, copy_bib_record, xact_start, xact_finish, target_copy,
281         circ_lib, circ_staff, checkin_staff, checkin_lib, renewal_remaining, grace_period, due_date,
282         stop_fines_time, checkin_time, create_time, duration, fine_interval, recurring_fine,
283         max_fine, phone_renewal, desk_renewal, opac_renewal, duration_rule, recurring_fine_rule,
284         max_fine_rule, stop_fines, workstation, checkin_workstation, checkin_scan_time, parent_circ)
285       SELECT
286         id,usr_post_code, usr_home_ou, usr_profile, usr_birth_year, copy_call_number, copy_location,
287         copy_owning_lib, copy_circ_lib, copy_bib_record, xact_start, xact_finish, target_copy,
288         circ_lib, circ_staff, checkin_staff, checkin_lib, renewal_remaining, grace_period, due_date,
289         stop_fines_time, checkin_time, create_time, duration, fine_interval, recurring_fine,
290         max_fine, phone_renewal, desk_renewal, opac_renewal, duration_rule, recurring_fine_rule,
291         max_fine_rule, stop_fines, workstation, checkin_workstation, checkin_scan_time, parent_circ
292         FROM action.all_circulation WHERE id = OLD.id;
293
294     RETURN OLD;
295 END;
296 $$ LANGUAGE 'plpgsql';
297
298 CREATE TRIGGER action_circulation_aging_tgr
299         BEFORE DELETE ON action.circulation
300         FOR EACH ROW
301         EXECUTE PROCEDURE action.age_circ_on_delete ();
302
303
304 CREATE OR REPLACE FUNCTION action.age_parent_circ_on_delete () RETURNS TRIGGER AS $$
305 BEGIN
306
307     -- Having deleted a renewal, we can delete the original circulation (or a previous
308     -- renewal, if that's what parent_circ is pointing to).  That deletion will trigger
309     -- deletion of any prior parents, etc. recursively.
310
311     IF OLD.parent_circ IS NOT NULL THEN
312         DELETE FROM action.circulation
313         WHERE id = OLD.parent_circ;
314     END IF;
315
316     RETURN OLD;
317 END;
318 $$ LANGUAGE 'plpgsql';
319
320 CREATE TRIGGER age_parent_circ
321         AFTER DELETE ON action.circulation
322         FOR EACH ROW
323         EXECUTE PROCEDURE action.age_parent_circ_on_delete ();
324
325
326 CREATE OR REPLACE VIEW action.open_circulation AS
327         SELECT  *
328           FROM  action.circulation
329           WHERE checkin_time IS NULL
330           ORDER BY due_date;
331                 
332
333 CREATE OR REPLACE VIEW action.billable_circulations AS
334         SELECT  *
335           FROM  action.circulation
336           WHERE xact_finish IS NULL;
337
338 CREATE OR REPLACE FUNCTION action.circulation_claims_returned () RETURNS TRIGGER AS $$
339 BEGIN
340         IF OLD.stop_fines IS NULL OR OLD.stop_fines <> NEW.stop_fines THEN
341                 IF NEW.stop_fines = 'CLAIMSRETURNED' THEN
342                         UPDATE actor.usr SET claims_returned_count = claims_returned_count + 1 WHERE id = NEW.usr;
343                 END IF;
344                 IF NEW.stop_fines = 'CLAIMSNEVERCHECKEDOUT' THEN
345                         UPDATE actor.usr SET claims_never_checked_out_count = claims_never_checked_out_count + 1 WHERE id = NEW.usr;
346                 END IF;
347                 IF NEW.stop_fines = 'LOST' THEN
348                         UPDATE asset.copy SET status = 3 WHERE id = NEW.target_copy;
349                 END IF;
350         END IF;
351         RETURN NEW;
352 END;
353 $$ LANGUAGE 'plpgsql';
354 CREATE TRIGGER action_circulation_stop_fines_tgr
355         BEFORE UPDATE ON action.circulation
356         FOR EACH ROW
357         EXECUTE PROCEDURE action.circulation_claims_returned ();
358
359 CREATE TABLE action.hold_request_cancel_cause (
360     id      SERIAL  PRIMARY KEY,
361     label   TEXT    UNIQUE
362 );
363
364 CREATE TABLE action.hold_request (
365         id                      SERIAL                          PRIMARY KEY,
366         request_time            TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW(),
367         capture_time            TIMESTAMP WITH TIME ZONE,
368         fulfillment_time        TIMESTAMP WITH TIME ZONE,
369         checkin_time            TIMESTAMP WITH TIME ZONE,
370         return_time             TIMESTAMP WITH TIME ZONE,
371         prev_check_time         TIMESTAMP WITH TIME ZONE,
372         expire_time             TIMESTAMP WITH TIME ZONE,
373         cancel_time             TIMESTAMP WITH TIME ZONE,
374         cancel_cause    INT REFERENCES action.hold_request_cancel_cause (id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED,
375         cancel_note             TEXT,
376         target                  BIGINT                          NOT NULL, -- see hold_type
377         current_copy            BIGINT,                         -- REFERENCES asset.copy (id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED,  -- XXX could be an serial.unit now...
378         fulfillment_staff       INT                             REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
379         fulfillment_lib         INT                             REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
380         request_lib             INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
381         requestor               INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
382         usr                     INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
383         selection_ou            INT                             NOT NULL,
384         selection_depth         INT                             NOT NULL DEFAULT 0,
385         pickup_lib              INT                             NOT NULL REFERENCES actor.org_unit DEFERRABLE INITIALLY DEFERRED,
386         hold_type               TEXT                            NOT NULL, -- CHECK (hold_type IN ('M','T','V','C')),  -- XXX constraint too constraining...
387         holdable_formats        TEXT,
388         phone_notify            TEXT,
389         email_notify            BOOL                            NOT NULL DEFAULT FALSE,
390         sms_notify              TEXT,
391         sms_carrier             INT REFERENCES config.sms_carrier (id),
392         frozen                  BOOL                            NOT NULL DEFAULT FALSE,
393         thaw_date               TIMESTAMP WITH TIME ZONE,
394         shelf_time              TIMESTAMP WITH TIME ZONE,
395     cut_in_line     BOOL,
396         mint_condition  BOOL NOT NULL DEFAULT TRUE,
397         shelf_expire_time TIMESTAMPTZ,
398         current_shelf_lib INT REFERENCES actor.org_unit DEFERRABLE INITIALLY DEFERRED,
399     behind_desk BOOLEAN NOT NULL DEFAULT FALSE
400 );
401 ALTER TABLE action.hold_request ADD CONSTRAINT sms_check CHECK (
402     sms_notify IS NULL
403     OR sms_carrier IS NOT NULL -- and implied sms_notify IS NOT NULL
404 );
405
406
407 CREATE OR REPLACE FUNCTION action.hold_request_clear_map () RETURNS TRIGGER AS $$
408 BEGIN
409   DELETE FROM action.hold_copy_map WHERE hold = NEW.id;
410   RETURN NEW;
411 END;
412 $$ LANGUAGE PLPGSQL;
413
414 CREATE TRIGGER hold_request_clear_map_tgr
415     AFTER UPDATE ON action.hold_request
416     FOR EACH ROW
417     WHEN (
418         (NEW.cancel_time IS NOT NULL AND OLD.cancel_time IS NULL)
419         OR (NEW.fulfillment_time IS NOT NULL AND OLD.fulfillment_time IS NULL)
420     )
421     EXECUTE PROCEDURE action.hold_request_clear_map();
422
423 CREATE INDEX hold_request_target_idx ON action.hold_request (target);
424 CREATE INDEX hold_request_usr_idx ON action.hold_request (usr);
425 CREATE INDEX hold_request_pickup_lib_idx ON action.hold_request (pickup_lib);
426 CREATE INDEX hold_request_current_copy_idx ON action.hold_request (current_copy);
427 CREATE INDEX hold_request_prev_check_time_idx ON action.hold_request (prev_check_time);
428 CREATE INDEX hold_request_fulfillment_staff_idx ON action.hold_request ( fulfillment_staff );
429 CREATE INDEX hold_request_requestor_idx         ON action.hold_request ( requestor );
430 CREATE INDEX hold_request_open_idx ON action.hold_request (id) WHERE cancel_time IS NULL AND fulfillment_time IS NULL;
431 CREATE INDEX hold_request_current_copy_before_cap_idx ON action.hold_request (current_copy) WHERE capture_time IS NULL AND cancel_time IS NULL;
432 CREATE UNIQUE INDEX hold_request_capture_protect_idx ON action.hold_request (current_copy) WHERE current_copy IS NOT NULL AND capture_time IS NOT NULL AND cancel_time IS NULL AND fulfillment_time IS NULL;
433 CREATE INDEX hold_request_copy_capture_time_idx ON action.hold_request (current_copy,capture_time);
434 CREATE INDEX hold_request_open_captured_shelf_lib_idx ON action.hold_request (current_shelf_lib) WHERE capture_time IS NOT NULL AND fulfillment_time IS NULL AND (pickup_lib <> current_shelf_lib);
435 CREATE INDEX hold_fulfillment_time_idx ON action.hold_request (fulfillment_time) WHERE fulfillment_time IS NOT NULL;
436 CREATE INDEX hold_request_time_idx ON action.hold_request (request_time);
437
438
439 CREATE TABLE action.hold_request_note (
440
441     id     BIGSERIAL PRIMARY KEY,
442     hold   BIGINT    NOT NULL REFERENCES action.hold_request (id)
443                               ON DELETE CASCADE
444                               DEFERRABLE INITIALLY DEFERRED,
445     title  TEXT      NOT NULL,
446     body   TEXT      NOT NULL,
447     slip   BOOL      NOT NULL DEFAULT FALSE,
448     pub    BOOL      NOT NULL DEFAULT FALSE,
449     staff  BOOL      NOT NULL DEFAULT FALSE  -- created by staff
450
451 );
452 CREATE INDEX ahrn_hold_idx ON action.hold_request_note (hold);
453
454
455 CREATE TABLE action.hold_notification (
456         id              SERIAL                          PRIMARY KEY,
457         hold            INT                             NOT NULL REFERENCES action.hold_request (id)
458                                                                         ON DELETE CASCADE
459                                                                         DEFERRABLE INITIALLY DEFERRED,
460         notify_staff    INT                     REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
461         notify_time     TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW(),
462         method          TEXT                            NOT NULL, -- email address or phone number
463         note            TEXT
464 );
465 CREATE INDEX ahn_hold_idx ON action.hold_notification (hold);
466 CREATE INDEX ahn_notify_staff_idx ON action.hold_notification ( notify_staff );
467
468 CREATE TABLE action.hold_copy_map (
469         id              BIGSERIAL       PRIMARY KEY,
470         hold            INT     NOT NULL REFERENCES action.hold_request (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
471         target_copy     BIGINT  NOT NULL, -- REFERENCES asset.copy (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, -- XXX could be an serial.issuance
472         proximity       NUMERIC,
473         CONSTRAINT copy_once_per_hold UNIQUE (hold,target_copy)
474 );
475 -- CREATE INDEX acm_hold_idx ON action.hold_copy_map (hold);
476 CREATE INDEX acm_copy_idx ON action.hold_copy_map (target_copy);
477
478 CREATE TABLE action.transit_copy (
479         id                      SERIAL                          PRIMARY KEY,
480         source_send_time        TIMESTAMP WITH TIME ZONE,
481         dest_recv_time          TIMESTAMP WITH TIME ZONE,
482         target_copy             BIGINT                          NOT NULL, -- REFERENCES asset.copy (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, -- XXX could be an serial.issuance
483         source                  INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
484         dest                    INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
485         prev_hop                INT                             REFERENCES action.transit_copy (id) DEFERRABLE INITIALLY DEFERRED,
486         copy_status             INT                             NOT NULL REFERENCES config.copy_status (id) DEFERRABLE INITIALLY DEFERRED,
487         persistant_transfer     BOOL                            NOT NULL DEFAULT FALSE,
488         prev_dest       INT                             REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED
489 );
490 CREATE INDEX active_transit_dest_idx ON "action".transit_copy (dest); 
491 CREATE INDEX active_transit_source_idx ON "action".transit_copy (source);
492 CREATE INDEX active_transit_cp_idx ON "action".transit_copy (target_copy);
493
494
495 CREATE TABLE action.hold_transit_copy (
496         hold    INT     REFERENCES action.hold_request (id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED
497 ) INHERITS (action.transit_copy);
498 ALTER TABLE action.hold_transit_copy ADD PRIMARY KEY (id);
499 -- ALTER TABLE action.hold_transit_copy ADD CONSTRAINT ahtc_tc_fkey FOREIGN KEY (target_copy) REFERENCES asset.copy (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; -- XXX could be an serial.issuance
500 CREATE INDEX active_hold_transit_dest_idx ON "action".hold_transit_copy (dest);
501 CREATE INDEX active_hold_transit_source_idx ON "action".hold_transit_copy (source);
502 CREATE INDEX active_hold_transit_cp_idx ON "action".hold_transit_copy (target_copy);
503 CREATE INDEX hold_transit_copy_hold_idx on action.hold_transit_copy (hold);
504
505
506 CREATE TABLE action.unfulfilled_hold_list (
507         id              BIGSERIAL                       PRIMARY KEY,
508         current_copy    BIGINT                          NOT NULL,
509         hold            INT                             NOT NULL,
510         circ_lib        INT                             NOT NULL,
511         fail_time       TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW()
512 );
513 CREATE INDEX uhr_hold_idx ON action.unfulfilled_hold_list (hold);
514
515 CREATE OR REPLACE VIEW action.unfulfilled_hold_loops AS
516     SELECT  u.hold,
517             c.circ_lib,
518             count(*)
519       FROM  action.unfulfilled_hold_list u
520             JOIN asset.copy c ON (c.id = u.current_copy)
521       GROUP BY 1,2;
522
523 CREATE OR REPLACE VIEW action.unfulfilled_hold_min_loop AS
524     SELECT  hold,
525             min(count)
526       FROM  action.unfulfilled_hold_loops
527       GROUP BY 1;
528
529 CREATE OR REPLACE VIEW action.unfulfilled_hold_innermost_loop AS
530     SELECT  DISTINCT l.*
531       FROM  action.unfulfilled_hold_loops l
532             JOIN action.unfulfilled_hold_min_loop m USING (hold)
533       WHERE l.count = m.min;
534
535 CREATE VIEW action.unfulfilled_hold_max_loop AS
536     SELECT  hold,
537             max(count) AS max
538       FROM  action.unfulfilled_hold_loops
539       GROUP BY 1;
540
541
542 CREATE TABLE action.aged_hold_request (
543     usr_post_code               TEXT,
544     usr_home_ou         INT     NOT NULL,
545     usr_profile         INT     NOT NULL,
546     usr_birth_year              INT,
547     staff_placed        BOOLEAN NOT NULL,
548     LIKE action.hold_request
549 );
550 ALTER TABLE action.aged_hold_request
551       ADD PRIMARY KEY (id),
552       DROP COLUMN usr,
553       DROP COLUMN requestor,
554       DROP COLUMN sms_carrier,
555       ALTER COLUMN phone_notify TYPE BOOLEAN
556             USING CASE WHEN phone_notify IS NULL OR phone_notify = '' THEN FALSE ELSE TRUE END,
557       ALTER COLUMN sms_notify TYPE BOOLEAN
558             USING CASE WHEN sms_notify IS NULL OR sms_notify = '' THEN FALSE ELSE TRUE END,
559       ALTER COLUMN phone_notify SET NOT NULL,
560       ALTER COLUMN sms_notify SET NOT NULL;
561 CREATE INDEX aged_hold_request_target_idx ON action.aged_hold_request (target);
562 CREATE INDEX aged_hold_request_pickup_lib_idx ON action.aged_hold_request (pickup_lib);
563 CREATE INDEX aged_hold_request_current_copy_idx ON action.aged_hold_request (current_copy);
564 CREATE INDEX aged_hold_request_fulfillment_staff_idx ON action.aged_hold_request ( fulfillment_staff );
565
566 CREATE OR REPLACE VIEW action.all_hold_request AS
567     SELECT DISTINCT
568            COALESCE(a.post_code, b.post_code) AS usr_post_code,
569            p.home_ou AS usr_home_ou,
570            p.profile AS usr_profile,
571            EXTRACT(YEAR FROM p.dob)::INT AS usr_birth_year,
572            CAST(ahr.requestor <> ahr.usr AS BOOLEAN) AS staff_placed,
573            ahr.id,
574            ahr.request_time,
575            ahr.capture_time,
576            ahr.fulfillment_time,
577            ahr.checkin_time,
578            ahr.return_time,
579            ahr.prev_check_time,
580            ahr.expire_time,
581            ahr.cancel_time,
582            ahr.cancel_cause,
583            ahr.cancel_note,
584            ahr.target,
585            ahr.current_copy,
586            ahr.fulfillment_staff,
587            ahr.fulfillment_lib,
588            ahr.request_lib,
589            ahr.selection_ou,
590            ahr.selection_depth,
591            ahr.pickup_lib,
592            ahr.hold_type,
593            ahr.holdable_formats,
594            CASE
595            WHEN ahr.phone_notify IS NULL THEN FALSE
596            WHEN ahr.phone_notify = '' THEN FALSE
597            ELSE TRUE
598            END AS phone_notify,
599            ahr.email_notify,
600            CASE
601            WHEN ahr.sms_notify IS NULL THEN FALSE
602            WHEN ahr.sms_notify = '' THEN FALSE
603            ELSE TRUE
604            END AS sms_notify,
605            ahr.frozen,
606            ahr.thaw_date,
607            ahr.shelf_time,
608            ahr.cut_in_line,
609            ahr.mint_condition,
610            ahr.shelf_expire_time,
611            ahr.current_shelf_lib,
612            ahr.behind_desk
613     FROM action.hold_request ahr
614          JOIN actor.usr p ON (ahr.usr = p.id)
615          LEFT JOIN actor.usr_address a ON (p.mailing_address = a.id)
616          LEFT JOIN actor.usr_address b ON (p.billing_address = b.id)
617     UNION ALL
618     SELECT 
619            usr_post_code,
620            usr_home_ou,
621            usr_profile,
622            usr_birth_year,
623            staff_placed,
624            id,
625            request_time,
626            capture_time,
627            fulfillment_time,
628            checkin_time,
629            return_time,
630            prev_check_time,
631            expire_time,
632            cancel_time,
633            cancel_cause,
634            cancel_note,
635            target,
636            current_copy,
637            fulfillment_staff,
638            fulfillment_lib,
639            request_lib,
640            selection_ou,
641            selection_depth,
642            pickup_lib,
643            hold_type,
644            holdable_formats,
645            phone_notify,
646            email_notify,
647            sms_notify,
648            frozen,
649            thaw_date,
650            shelf_time,
651            cut_in_line,
652            mint_condition,
653            shelf_expire_time,
654            current_shelf_lib,
655            behind_desk
656     FROM action.aged_hold_request;
657
658 CREATE OR REPLACE FUNCTION action.age_hold_on_delete () RETURNS TRIGGER AS $$
659 DECLARE
660 BEGIN
661     -- Archive a copy of the old row to action.aged_hold_request
662
663     INSERT INTO action.aged_hold_request
664            (usr_post_code,
665             usr_home_ou,
666             usr_profile,
667             usr_birth_year,
668             staff_placed,
669             id,
670             request_time,
671             capture_time,
672             fulfillment_time,
673             checkin_time,
674             return_time,
675             prev_check_time,
676             expire_time,
677             cancel_time,
678             cancel_cause,
679             cancel_note,
680             target,
681             current_copy,
682             fulfillment_staff,
683             fulfillment_lib,
684             request_lib,
685             selection_ou,
686             selection_depth,
687             pickup_lib,
688             hold_type,
689             holdable_formats,
690             phone_notify,
691             email_notify,
692             sms_notify,
693             frozen,
694             thaw_date,
695             shelf_time,
696             cut_in_line,
697             mint_condition,
698             shelf_expire_time,
699             current_shelf_lib,
700             behind_desk)
701       SELECT 
702            usr_post_code,
703            usr_home_ou,
704            usr_profile,
705            usr_birth_year,
706            staff_placed,
707            id,
708            request_time,
709            capture_time,
710            fulfillment_time,
711            checkin_time,
712            return_time,
713            prev_check_time,
714            expire_time,
715            cancel_time,
716            cancel_cause,
717            cancel_note,
718            target,
719            current_copy,
720            fulfillment_staff,
721            fulfillment_lib,
722            request_lib,
723            selection_ou,
724            selection_depth,
725            pickup_lib,
726            hold_type,
727            holdable_formats,
728            phone_notify,
729            email_notify,
730            sms_notify,
731            frozen,
732            thaw_date,
733            shelf_time,
734            cut_in_line,
735            mint_condition,
736            shelf_expire_time,
737            current_shelf_lib,
738            behind_desk
739         FROM action.all_hold_request WHERE id = OLD.id;
740
741     RETURN OLD;
742 END;
743 $$ LANGUAGE 'plpgsql';
744
745 CREATE TRIGGER action_hold_request_aging_tgr
746         BEFORE DELETE ON action.hold_request
747         FOR EACH ROW
748         EXECUTE PROCEDURE action.age_hold_on_delete ();
749
750 CREATE TABLE action.fieldset (
751     id              SERIAL          PRIMARY KEY,
752     owner           INT             NOT NULL REFERENCES actor.usr (id)
753                                     DEFERRABLE INITIALLY DEFERRED,
754         owning_lib      INT             NOT NULL REFERENCES actor.org_unit (id)
755                                     DEFERRABLE INITIALLY DEFERRED,
756         status          TEXT            NOT NULL
757                                         CONSTRAINT valid_status CHECK ( status in
758                                                                         ( 'PENDING', 'APPLIED', 'ERROR' )),
759     creation_time   TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
760     scheduled_time  TIMESTAMPTZ,
761     applied_time    TIMESTAMPTZ,
762     classname       TEXT            NOT NULL, -- an IDL class name
763     name            TEXT            NOT NULL,
764     stored_query    INT             REFERENCES query.stored_query (id)
765                                     DEFERRABLE INITIALLY DEFERRED,
766     pkey_value      TEXT,
767         CONSTRAINT lib_name_unique UNIQUE (owning_lib, name),
768     CONSTRAINT fieldset_one_or_the_other CHECK (
769         (stored_query IS NOT NULL AND pkey_value IS NULL) OR
770         (pkey_value IS NOT NULL AND stored_query IS NULL)
771     )
772         -- the CHECK constraint means we can update the fields for a single
773         -- row without all the extra overhead involved in a query
774 );
775
776 CREATE INDEX action_fieldset_sched_time_idx ON action.fieldset( scheduled_time );
777 CREATE INDEX action_owner_idx               ON action.fieldset( owner );
778
779
780 CREATE TABLE action.fieldset_col_val (
781     id              SERIAL  PRIMARY KEY,
782     fieldset        INT     NOT NULL REFERENCES action.fieldset
783                                          ON DELETE CASCADE
784                                          DEFERRABLE INITIALLY DEFERRED,
785     col             TEXT    NOT NULL,  -- "field" from the idl ... the column on the table
786     val             TEXT,              -- value for the column ... NULL means, well, NULL
787     CONSTRAINT fieldset_col_once_per_set UNIQUE (fieldset, col)
788 );
789
790
791 -- represents a circ chain summary
792 CREATE TYPE action.circ_chain_summary AS (
793     num_circs INTEGER,
794     start_time TIMESTAMP WITH TIME ZONE,
795     checkout_workstation TEXT,
796     last_renewal_time TIMESTAMP WITH TIME ZONE, -- NULL if no renewals
797     last_stop_fines TEXT,
798     last_stop_fines_time TIMESTAMP WITH TIME ZONE,
799     last_renewal_workstation TEXT, -- NULL if no renewals
800     last_checkin_workstation TEXT,
801     last_checkin_time TIMESTAMP WITH TIME ZONE,
802     last_checkin_scan_time TIMESTAMP WITH TIME ZONE
803 );
804
805
806 CREATE OR REPLACE FUNCTION action.circ_chain ( ctx_circ_id BIGINT ) RETURNS SETOF action.circulation AS $$
807 DECLARE
808     tmp_circ action.circulation%ROWTYPE;
809     circ_0 action.circulation%ROWTYPE;
810 BEGIN
811
812     SELECT INTO tmp_circ * FROM action.circulation WHERE id = ctx_circ_id;
813
814     IF tmp_circ IS NULL THEN
815         RETURN NEXT tmp_circ;
816     END IF;
817     circ_0 := tmp_circ;
818
819     -- find the front of the chain
820     WHILE TRUE LOOP
821         SELECT INTO tmp_circ * FROM action.circulation WHERE id = tmp_circ.parent_circ;
822         IF tmp_circ IS NULL THEN
823             EXIT;
824         END IF;
825         circ_0 := tmp_circ;
826     END LOOP;
827
828     -- now send the circs to the caller, oldest to newest
829     tmp_circ := circ_0;
830     WHILE TRUE LOOP
831         IF tmp_circ IS NULL THEN
832             EXIT;
833         END IF;
834         RETURN NEXT tmp_circ;
835         SELECT INTO tmp_circ * FROM action.circulation WHERE parent_circ = tmp_circ.id;
836     END LOOP;
837
838 END;
839 $$ LANGUAGE 'plpgsql';
840
841 CREATE OR REPLACE FUNCTION action.summarize_circ_chain ( ctx_circ_id BIGINT ) RETURNS action.circ_chain_summary AS $$
842
843 DECLARE
844
845     -- first circ in the chain
846     circ_0 action.circulation%ROWTYPE;
847
848     -- last circ in the chain
849     circ_n action.circulation%ROWTYPE;
850
851     -- circ chain under construction
852     chain action.circ_chain_summary;
853     tmp_circ action.circulation%ROWTYPE;
854
855 BEGIN
856     
857     chain.num_circs := 0;
858     FOR tmp_circ IN SELECT * FROM action.circ_chain(ctx_circ_id) LOOP
859
860         IF chain.num_circs = 0 THEN
861             circ_0 := tmp_circ;
862         END IF;
863
864         chain.num_circs := chain.num_circs + 1;
865         circ_n := tmp_circ;
866     END LOOP;
867
868     chain.start_time := circ_0.xact_start;
869     chain.last_stop_fines := circ_n.stop_fines;
870     chain.last_stop_fines_time := circ_n.stop_fines_time;
871     chain.last_checkin_time := circ_n.checkin_time;
872     chain.last_checkin_scan_time := circ_n.checkin_scan_time;
873     SELECT INTO chain.checkout_workstation name FROM actor.workstation WHERE id = circ_0.workstation;
874     SELECT INTO chain.last_checkin_workstation name FROM actor.workstation WHERE id = circ_n.checkin_workstation;
875
876     IF chain.num_circs > 1 THEN
877         chain.last_renewal_time := circ_n.xact_start;
878         SELECT INTO chain.last_renewal_workstation name FROM actor.workstation WHERE id = circ_n.workstation;
879     END IF;
880
881     RETURN chain;
882
883 END;
884 $$ LANGUAGE 'plpgsql';
885
886 CREATE OR REPLACE FUNCTION action.usr_visible_holds (usr_id INT) RETURNS SETOF action.hold_request AS $func$
887 DECLARE
888     h               action.hold_request%ROWTYPE;
889     view_age        INTERVAL;
890     view_count      INT;
891     usr_view_count  actor.usr_setting%ROWTYPE;
892     usr_view_age    actor.usr_setting%ROWTYPE;
893     usr_view_start  actor.usr_setting%ROWTYPE;
894 BEGIN
895     SELECT * INTO usr_view_count FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.hold.retention_count';
896     SELECT * INTO usr_view_age FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.hold.retention_age';
897     SELECT * INTO usr_view_start FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.hold.retention_start';
898
899     FOR h IN
900         SELECT  *
901           FROM  action.hold_request
902           WHERE usr = usr_id
903                 AND fulfillment_time IS NULL
904                 AND cancel_time IS NULL
905           ORDER BY request_time DESC
906     LOOP
907         RETURN NEXT h;
908     END LOOP;
909
910     IF usr_view_start.value IS NULL THEN
911         RETURN;
912     END IF;
913
914     IF usr_view_age.value IS NOT NULL THEN
915         -- User opted in and supplied a retention age
916         IF oils_json_to_text(usr_view_age.value)::INTERVAL > AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ) THEN
917             view_age := AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ);
918         ELSE
919             view_age := oils_json_to_text(usr_view_age.value)::INTERVAL;
920         END IF;
921     ELSE
922         -- User opted in
923         view_age := AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ);
924     END IF;
925
926     IF usr_view_count.value IS NOT NULL THEN
927         view_count := oils_json_to_text(usr_view_count.value)::INT;
928     ELSE
929         view_count := 1000;
930     END IF;
931
932     -- show some fulfilled/canceled holds
933     FOR h IN
934         SELECT  *
935           FROM  action.hold_request
936           WHERE usr = usr_id
937                 AND ( fulfillment_time IS NOT NULL OR cancel_time IS NOT NULL )
938                 AND COALESCE(fulfillment_time, cancel_time) > NOW() - view_age
939           ORDER BY COALESCE(fulfillment_time, cancel_time) DESC
940           LIMIT view_count
941     LOOP
942         RETURN NEXT h;
943     END LOOP;
944
945     RETURN;
946 END;
947 $func$ LANGUAGE PLPGSQL;
948
949 CREATE OR REPLACE FUNCTION action.purge_circulations () RETURNS INT AS $func$
950 DECLARE
951     org_keep_age    INTERVAL;
952     org_use_last    BOOL = false;
953     org_age_is_min  BOOL = false;
954     org_keep_count  INT;
955
956     keep_age        INTERVAL;
957
958     target_acp      RECORD;
959     circ_chain_head action.circulation%ROWTYPE;
960     circ_chain_tail action.circulation%ROWTYPE;
961
962     count_purged    INT;
963     num_incomplete  INT;
964
965     last_finished   TIMESTAMP WITH TIME ZONE;
966 BEGIN
967
968     count_purged := 0;
969
970     SELECT value::INTERVAL INTO org_keep_age FROM config.global_flag WHERE name = 'history.circ.retention_age' AND enabled;
971
972     SELECT value::INT INTO org_keep_count FROM config.global_flag WHERE name = 'history.circ.retention_count' AND enabled;
973     IF org_keep_count IS NULL THEN
974         RETURN count_purged; -- Gimme a count to keep, or I keep them all, forever
975     END IF;
976
977     SELECT enabled INTO org_use_last FROM config.global_flag WHERE name = 'history.circ.retention_uses_last_finished';
978     SELECT enabled INTO org_age_is_min FROM config.global_flag WHERE name = 'history.circ.retention_age_is_min';
979
980     -- First, find copies with more than keep_count non-renewal circs
981     FOR target_acp IN
982         SELECT  target_copy,
983                 COUNT(*) AS total_real_circs
984           FROM  action.circulation
985           WHERE parent_circ IS NULL
986                 AND xact_finish IS NOT NULL
987           GROUP BY target_copy
988           HAVING COUNT(*) > org_keep_count
989     LOOP
990         -- And, for those, select circs that are finished and older than keep_age
991         FOR circ_chain_head IN
992             -- For reference, the subquery uses a window function to order the circs newest to oldest and number them
993             -- The outer query then uses that information to skip the most recent set the library wants to keep
994             -- End result is we don't care what order they come out in, as they are all potentials for deletion.
995             SELECT ac.* FROM action.circulation ac JOIN (
996               SELECT  rank() OVER (ORDER BY xact_start DESC), ac.id
997                 FROM  action.circulation ac
998                 WHERE ac.target_copy = target_acp.target_copy
999                   AND ac.parent_circ IS NULL
1000                 ORDER BY ac.xact_start ) ranked USING (id)
1001                 WHERE ranked.rank > org_keep_count
1002         LOOP
1003
1004             SELECT * INTO circ_chain_tail FROM action.circ_chain(circ_chain_head.id) ORDER BY xact_start DESC LIMIT 1;
1005             SELECT COUNT(CASE WHEN xact_finish IS NULL THEN 1 ELSE NULL END), MAX(xact_finish) INTO num_incomplete, last_finished FROM action.circ_chain(circ_chain_head.id);
1006             CONTINUE WHEN circ_chain_tail.xact_finish IS NULL OR num_incomplete > 0;
1007
1008             IF NOT org_use_last THEN
1009                 last_finished := circ_chain_tail.xact_finish;
1010             END IF;
1011
1012             keep_age := COALESCE( org_keep_age, '2000 years'::INTERVAL );
1013
1014             IF org_age_is_min THEN
1015                 keep_age := GREATEST( keep_age, org_keep_age );
1016             END IF;
1017
1018             CONTINUE WHEN AGE(NOW(), last_finished) < keep_age;
1019
1020             -- We've passed the purging tests, purge the circ chain starting at the end
1021             -- A trigger should auto-purge the rest of the chain.
1022             DELETE FROM action.circulation WHERE id = circ_chain_tail.id;
1023
1024             count_purged := count_purged + 1;
1025
1026         END LOOP;
1027     END LOOP;
1028
1029     return count_purged;
1030 END;
1031 $func$ LANGUAGE PLPGSQL;
1032
1033 CREATE OR REPLACE FUNCTION action.purge_holds() RETURNS INT AS $func$
1034 DECLARE
1035   current_hold RECORD;
1036   purged_holds INT;
1037   cgf_d INTERVAL;
1038   cgf_f INTERVAL;
1039   cgf_c INTERVAL;
1040   prev_usr INT;
1041   user_start TIMESTAMPTZ;
1042   user_age INTERVAL;
1043   user_count INT;
1044 BEGIN
1045   purged_holds := 0;
1046   SELECT INTO cgf_d value::INTERVAL FROM config.global_flag WHERE name = 'history.hold.retention_age' AND enabled;
1047   SELECT INTO cgf_f value::INTERVAL FROM config.global_flag WHERE name = 'history.hold.retention_age_fulfilled' AND enabled;
1048   SELECT INTO cgf_c value::INTERVAL FROM config.global_flag WHERE name = 'history.hold.retention_age_canceled' AND enabled;
1049   FOR current_hold IN
1050     SELECT
1051       rank() OVER (PARTITION BY usr ORDER BY COALESCE(fulfillment_time, cancel_time) DESC),
1052       cgf_cs.value::INTERVAL as cgf_cs,
1053       ahr.*
1054     FROM
1055       action.hold_request ahr
1056       LEFT JOIN config.global_flag cgf_cs ON (ahr.cancel_cause IS NOT NULL AND cgf_cs.name = 'history.hold.retention_age_canceled_' || ahr.cancel_cause AND cgf_cs.enabled)
1057     WHERE
1058       (fulfillment_time IS NOT NULL OR cancel_time IS NOT NULL)
1059   LOOP
1060     IF prev_usr IS NULL OR prev_usr != current_hold.usr THEN
1061       prev_usr := current_hold.usr;
1062       SELECT INTO user_start oils_json_to_text(value)::TIMESTAMPTZ FROM actor.usr_setting WHERE usr = prev_usr AND name = 'history.hold.retention_start';
1063       SELECT INTO user_age oils_json_to_text(value)::INTERVAL FROM actor.usr_setting WHERE usr = prev_usr AND name = 'history.hold.retention_age';
1064       SELECT INTO user_count oils_json_to_text(value)::INT FROM actor.usr_setting WHERE usr = prev_usr AND name = 'history.hold.retention_count';
1065       IF user_start IS NOT NULL THEN
1066         user_age := LEAST(user_age, AGE(NOW(), user_start));
1067       END IF;
1068       IF user_count IS NULL THEN
1069         user_count := 1000; -- Assumption based on the user visible holds routine
1070       END IF;
1071     END IF;
1072     -- Library keep age trumps user keep anything, for purposes of being able to hold on to things when staff canceled and such.
1073     IF current_hold.fulfillment_time IS NOT NULL AND current_hold.fulfillment_time > NOW() - COALESCE(cgf_f, cgf_d) THEN
1074       CONTINUE;
1075     END IF;
1076     IF current_hold.cancel_time IS NOT NULL AND current_hold.cancel_time > NOW() - COALESCE(current_hold.cgf_cs, cgf_c, cgf_d) THEN
1077       CONTINUE;
1078     END IF;
1079
1080     -- User keep age needs combining with count. If too old AND within the count, keep!
1081     IF user_start IS NOT NULL AND COALESCE(current_hold.fulfillment_time, current_hold.cancel_time) > NOW() - user_age AND current_hold.rank <= user_count THEN
1082       CONTINUE;
1083     END IF;
1084
1085     -- All checks should have passed, delete!
1086     DELETE FROM action.hold_request WHERE id = current_hold.id;
1087     purged_holds := purged_holds + 1;
1088   END LOOP;
1089   RETURN purged_holds;
1090 END;
1091 $func$ LANGUAGE plpgsql;
1092
1093 CREATE OR REPLACE FUNCTION action.apply_fieldset(
1094         fieldset_id IN INT,        -- id from action.fieldset
1095         table_name  IN TEXT,       -- table to be updated
1096         pkey_name   IN TEXT,       -- name of primary key column in that table
1097         query       IN TEXT        -- query constructed by qstore (for query-based
1098                                    --    fieldsets only; otherwise null
1099 )
1100 RETURNS TEXT AS $$
1101 DECLARE
1102         statement TEXT;
1103         fs_status TEXT;
1104         fs_pkey_value TEXT;
1105         fs_query TEXT;
1106         sep CHAR;
1107         status_code TEXT;
1108         msg TEXT;
1109         update_count INT;
1110         cv RECORD;
1111 BEGIN
1112         -- Sanity checks
1113         IF fieldset_id IS NULL THEN
1114                 RETURN 'Fieldset ID parameter is NULL';
1115         END IF;
1116         IF table_name IS NULL THEN
1117                 RETURN 'Table name parameter is NULL';
1118         END IF;
1119         IF pkey_name IS NULL THEN
1120                 RETURN 'Primary key name parameter is NULL';
1121         END IF;
1122         --
1123         statement := 'UPDATE ' || table_name || ' SET';
1124         --
1125         SELECT
1126                 status,
1127                 quote_literal( pkey_value )
1128         INTO
1129                 fs_status,
1130                 fs_pkey_value
1131         FROM
1132                 action.fieldset
1133         WHERE
1134                 id = fieldset_id;
1135         --
1136         IF fs_status IS NULL THEN
1137                 RETURN 'No fieldset found for id = ' || fieldset_id;
1138         ELSIF fs_status = 'APPLIED' THEN
1139                 RETURN 'Fieldset ' || fieldset_id || ' has already been applied';
1140         END IF;
1141         --
1142         sep := '';
1143         FOR cv IN
1144                 SELECT  col,
1145                                 val
1146                 FROM    action.fieldset_col_val
1147                 WHERE   fieldset = fieldset_id
1148         LOOP
1149                 statement := statement || sep || ' ' || cv.col
1150                                          || ' = ' || coalesce( quote_literal( cv.val ), 'NULL' );
1151                 sep := ',';
1152         END LOOP;
1153         --
1154         IF sep = '' THEN
1155                 RETURN 'Fieldset ' || fieldset_id || ' has no column values defined';
1156         END IF;
1157         --
1158         -- Add the WHERE clause.  This differs according to whether it's a
1159         -- single-row fieldset or a query-based fieldset.
1160         --
1161         IF query IS NULL        AND fs_pkey_value IS NULL THEN
1162                 RETURN 'Incomplete fieldset: neither a primary key nor a query available';
1163         ELSIF query IS NOT NULL AND fs_pkey_value IS NULL THEN
1164             fs_query := rtrim( query, ';' );
1165             statement := statement || ' WHERE ' || pkey_name || ' IN ( '
1166                          || fs_query || ' );';
1167         ELSIF query IS NULL     AND fs_pkey_value IS NOT NULL THEN
1168                 statement := statement || ' WHERE ' || pkey_name || ' = '
1169                                      || fs_pkey_value || ';';
1170         ELSE  -- both are not null
1171                 RETURN 'Ambiguous fieldset: both a primary key and a query provided';
1172         END IF;
1173         --
1174         -- Execute the update
1175         --
1176         BEGIN
1177                 EXECUTE statement;
1178                 GET DIAGNOSTICS update_count = ROW_COUNT;
1179                 --
1180                 IF UPDATE_COUNT > 0 THEN
1181                         status_code := 'APPLIED';
1182                         msg := NULL;
1183                 ELSE
1184                         status_code := 'ERROR';
1185                         msg := 'No eligible rows found for fieldset ' || fieldset_id;
1186         END IF;
1187         EXCEPTION WHEN OTHERS THEN
1188                 status_code := 'ERROR';
1189                 msg := 'Unable to apply fieldset ' || fieldset_id
1190                            || ': ' || sqlerrm;
1191         END;
1192         --
1193         -- Update fieldset status
1194         --
1195         UPDATE action.fieldset
1196         SET status       = status_code,
1197             applied_time = now()
1198         WHERE id = fieldset_id;
1199         --
1200         RETURN msg;
1201 END;
1202 $$ LANGUAGE plpgsql;
1203
1204 COMMENT ON FUNCTION action.apply_fieldset( INT, TEXT, TEXT, TEXT ) IS $$
1205 Applies a specified fieldset, using a supplied table name and primary
1206 key name.  The query parameter should be non-null only for
1207 query-based fieldsets.
1208
1209 Returns NULL if successful, or an error message if not.
1210 $$;
1211
1212 CREATE OR REPLACE FUNCTION action.hold_copy_calculated_proximity(
1213     ahr_id INT,
1214     acp_id BIGINT,
1215     copy_context_ou INT DEFAULT NULL
1216     -- TODO maybe? hold_context_ou INT DEFAULT NULL.  This would optionally
1217     -- support an "ahprox" measurement: adjust prox between copy circ lib and
1218     -- hold request lib, but I'm unsure whether to use this theoretical
1219     -- argument only in the baseline calculation or later in the other
1220     -- queries in this function.
1221 ) RETURNS NUMERIC AS $f$
1222 DECLARE
1223     aoupa           actor.org_unit_proximity_adjustment%ROWTYPE;
1224     ahr             action.hold_request%ROWTYPE;
1225     acp             asset.copy%ROWTYPE;
1226     acn             asset.call_number%ROWTYPE;
1227     acl             asset.copy_location%ROWTYPE;
1228     baseline_prox   NUMERIC;
1229
1230     icl_list        INT[];
1231     iol_list        INT[];
1232     isl_list        INT[];
1233     hpl_list        INT[];
1234     hrl_list        INT[];
1235
1236 BEGIN
1237
1238     SELECT * INTO ahr FROM action.hold_request WHERE id = ahr_id;
1239     SELECT * INTO acp FROM asset.copy WHERE id = acp_id;
1240     SELECT * INTO acn FROM asset.call_number WHERE id = acp.call_number;
1241     SELECT * INTO acl FROM asset.copy_location WHERE id = acp.location;
1242
1243     IF copy_context_ou IS NULL THEN
1244         copy_context_ou := acp.circ_lib;
1245     END IF;
1246
1247     -- First, gather the baseline proximity of "here" to pickup lib
1248     SELECT prox INTO baseline_prox FROM actor.org_unit_proximity WHERE from_org = copy_context_ou AND to_org = ahr.pickup_lib;
1249
1250     -- Find any absolute adjustments, and set the baseline prox to that
1251     SELECT  adj.* INTO aoupa
1252       FROM  actor.org_unit_proximity_adjustment adj
1253             LEFT JOIN actor.org_unit_ancestors_distance(copy_context_ou) acp_cl ON (acp_cl.id = adj.item_circ_lib)
1254             LEFT JOIN actor.org_unit_ancestors_distance(acn.owning_lib) acn_ol ON (acn_ol.id = adj.item_owning_lib)
1255             LEFT JOIN actor.org_unit_ancestors_distance(acl.owning_lib) acl_ol ON (acl_ol.id = adj.copy_location)
1256             LEFT JOIN actor.org_unit_ancestors_distance(ahr.pickup_lib) ahr_pl ON (ahr_pl.id = adj.hold_pickup_lib)
1257             LEFT JOIN actor.org_unit_ancestors_distance(ahr.request_lib) ahr_rl ON (ahr_rl.id = adj.hold_request_lib)
1258       WHERE (adj.circ_mod IS NULL OR adj.circ_mod = acp.circ_modifier) AND
1259             (adj.item_circ_lib IS NULL OR adj.item_circ_lib = acp_cl.id) AND
1260             (adj.item_owning_lib IS NULL OR adj.item_owning_lib = acn_ol.id) AND
1261             (adj.copy_location IS NULL OR adj.copy_location = acl_ol.id) AND
1262             (adj.hold_pickup_lib IS NULL OR adj.hold_pickup_lib = ahr_pl.id) AND
1263             (adj.hold_request_lib IS NULL OR adj.hold_request_lib = ahr_rl.id) AND
1264             absolute_adjustment AND
1265             COALESCE(acp_cl.id, acn_ol.id, acl_ol.id, ahr_pl.id, ahr_rl.id) IS NOT NULL
1266       ORDER BY
1267             COALESCE(acp_cl.distance,999)
1268                 + COALESCE(acn_ol.distance,999)
1269                 + COALESCE(acl_ol.distance,999)
1270                 + COALESCE(ahr_pl.distance,999)
1271                 + COALESCE(ahr_rl.distance,999),
1272             adj.pos
1273       LIMIT 1;
1274
1275     IF FOUND THEN
1276         baseline_prox := aoupa.prox_adjustment;
1277     END IF;
1278
1279     -- Now find any relative adjustments, and change the baseline prox based on them
1280     FOR aoupa IN
1281         SELECT  adj.* 
1282           FROM  actor.org_unit_proximity_adjustment adj
1283                 LEFT JOIN actor.org_unit_ancestors_distance(copy_context_ou) acp_cl ON (acp_cl.id = adj.item_circ_lib)
1284                 LEFT JOIN actor.org_unit_ancestors_distance(acn.owning_lib) acn_ol ON (acn_ol.id = adj.item_owning_lib)
1285                 LEFT JOIN actor.org_unit_ancestors_distance(acl.owning_lib) acl_ol ON (acn_ol.id = adj.copy_location)
1286                 LEFT JOIN actor.org_unit_ancestors_distance(ahr.pickup_lib) ahr_pl ON (ahr_pl.id = adj.hold_pickup_lib)
1287                 LEFT JOIN actor.org_unit_ancestors_distance(ahr.request_lib) ahr_rl ON (ahr_rl.id = adj.hold_request_lib)
1288           WHERE (adj.circ_mod IS NULL OR adj.circ_mod = acp.circ_modifier) AND
1289                 (adj.item_circ_lib IS NULL OR adj.item_circ_lib = acp_cl.id) AND
1290                 (adj.item_owning_lib IS NULL OR adj.item_owning_lib = acn_ol.id) AND
1291                 (adj.copy_location IS NULL OR adj.copy_location = acl_ol.id) AND
1292                 (adj.hold_pickup_lib IS NULL OR adj.hold_pickup_lib = ahr_pl.id) AND
1293                 (adj.hold_request_lib IS NULL OR adj.hold_request_lib = ahr_rl.id) AND
1294                 NOT absolute_adjustment AND
1295                 COALESCE(acp_cl.id, acn_ol.id, acl_ol.id, ahr_pl.id, ahr_rl.id) IS NOT NULL
1296     LOOP
1297         baseline_prox := baseline_prox + aoupa.prox_adjustment;
1298     END LOOP;
1299
1300     RETURN baseline_prox;
1301 END;
1302 $f$ LANGUAGE PLPGSQL;
1303
1304 CREATE OR REPLACE FUNCTION action.hold_copy_calculated_proximity_update () RETURNS TRIGGER AS $f$
1305 BEGIN
1306     NEW.proximity := action.hold_copy_calculated_proximity(NEW.hold,NEW.target_copy);
1307     RETURN NEW;
1308 END;
1309 $f$ LANGUAGE PLPGSQL;
1310
1311 CREATE TRIGGER hold_copy_proximity_update_tgr BEFORE INSERT OR UPDATE ON action.hold_copy_map FOR EACH ROW EXECUTE PROCEDURE action.hold_copy_calculated_proximity_update ();
1312
1313 CREATE TABLE action.usr_circ_history (
1314     id           BIGSERIAL PRIMARY KEY,
1315     usr          INTEGER NOT NULL REFERENCES actor.usr(id)
1316                  DEFERRABLE INITIALLY DEFERRED,
1317     xact_start   TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
1318     target_copy  BIGINT NOT NULL, -- asset.copy.id / serial.unit.id
1319     due_date     TIMESTAMP WITH TIME ZONE NOT NULL,
1320     checkin_time TIMESTAMP WITH TIME ZONE,
1321     source_circ  BIGINT REFERENCES action.circulation(id)
1322                  ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED
1323 );
1324
1325 CREATE TRIGGER action_usr_circ_history_target_copy_trig 
1326     AFTER INSERT OR UPDATE ON action.usr_circ_history 
1327     FOR EACH ROW EXECUTE PROCEDURE evergreen.fake_fkey_tgr('target_copy');
1328
1329 CREATE OR REPLACE FUNCTION action.maintain_usr_circ_history() 
1330     RETURNS TRIGGER AS $FUNK$
1331 DECLARE
1332     cur_circ  BIGINT;
1333     first_circ BIGINT;
1334 BEGIN                                                                          
1335
1336     -- Any retention value signifies history is enabled.
1337     -- This assumes that clearing these values via external 
1338     -- process deletes the action.usr_circ_history rows.
1339     -- TODO: replace these settings w/ a single bool setting?
1340     PERFORM 1 FROM actor.usr_setting 
1341         WHERE usr = NEW.usr AND value IS NOT NULL AND name IN (
1342             'history.circ.retention_age', 
1343             'history.circ.retention_start'
1344         );
1345
1346     IF NOT FOUND THEN
1347         RETURN NEW;
1348     END IF;
1349
1350     IF TG_OP = 'INSERT' AND NEW.parent_circ IS NULL THEN
1351         -- Starting a new circulation.  Insert the history row.
1352         INSERT INTO action.usr_circ_history 
1353             (usr, xact_start, target_copy, due_date, source_circ)
1354         VALUES (
1355             NEW.usr, 
1356             NEW.xact_start, 
1357             NEW.target_copy, 
1358             NEW.due_date, 
1359             NEW.id
1360         );
1361
1362         RETURN NEW;
1363     END IF;
1364
1365     -- find the first and last circs in the circ chain 
1366     -- for the currently modified circ.
1367     FOR cur_circ IN SELECT id FROM action.circ_chain(NEW.id) LOOP
1368         IF first_circ IS NULL THEN
1369             first_circ := cur_circ;
1370             CONTINUE;
1371         END IF;
1372         -- Allow the loop to continue so that at as the loop
1373         -- completes cur_circ points to the final circulation.
1374     END LOOP;
1375
1376     IF NEW.id <> cur_circ THEN
1377         -- Modifying an intermediate circ.  Ignore it.
1378         RETURN NEW;
1379     END IF;
1380
1381     -- Update the due_date/checkin_time on the history row if the current 
1382     -- circ is the last circ in the chain and an update is warranted.
1383
1384     UPDATE action.usr_circ_history 
1385         SET 
1386             due_date = NEW.due_date,
1387             checkin_time = NEW.checkin_time
1388         WHERE 
1389             source_circ = first_circ 
1390             AND (
1391                 due_date <> NEW.due_date OR (
1392                     (checkin_time IS NULL AND NEW.checkin_time IS NOT NULL) OR
1393                     (checkin_time IS NOT NULL AND NEW.checkin_time IS NULL) OR
1394                     (checkin_time <> NEW.checkin_time)
1395                 )
1396             );
1397     RETURN NEW;
1398 END;                                                                           
1399 $FUNK$ LANGUAGE PLPGSQL; 
1400
1401 CREATE TRIGGER maintain_usr_circ_history_tgr 
1402     AFTER INSERT OR UPDATE ON action.circulation 
1403     FOR EACH ROW EXECUTE PROCEDURE action.maintain_usr_circ_history();
1404
1405 COMMIT;