LP#902255: Protect against hold double-capture
[working/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
434
435 CREATE TABLE action.hold_request_note (
436
437     id     BIGSERIAL PRIMARY KEY,
438     hold   BIGINT    NOT NULL REFERENCES action.hold_request (id)
439                               ON DELETE CASCADE
440                               DEFERRABLE INITIALLY DEFERRED,
441     title  TEXT      NOT NULL,
442     body   TEXT      NOT NULL,
443     slip   BOOL      NOT NULL DEFAULT FALSE,
444     pub    BOOL      NOT NULL DEFAULT FALSE,
445     staff  BOOL      NOT NULL DEFAULT FALSE  -- created by staff
446
447 );
448 CREATE INDEX ahrn_hold_idx ON action.hold_request_note (hold);
449
450
451 CREATE TABLE action.hold_notification (
452         id              SERIAL                          PRIMARY KEY,
453         hold            INT                             NOT NULL REFERENCES action.hold_request (id)
454                                                                         ON DELETE CASCADE
455                                                                         DEFERRABLE INITIALLY DEFERRED,
456         notify_staff    INT                     REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
457         notify_time     TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW(),
458         method          TEXT                            NOT NULL, -- email address or phone number
459         note            TEXT
460 );
461 CREATE INDEX ahn_hold_idx ON action.hold_notification (hold);
462 CREATE INDEX ahn_notify_staff_idx ON action.hold_notification ( notify_staff );
463
464 CREATE TABLE action.hold_copy_map (
465         id              BIGSERIAL       PRIMARY KEY,
466         hold            INT     NOT NULL REFERENCES action.hold_request (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
467         target_copy     BIGINT  NOT NULL, -- REFERENCES asset.copy (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, -- XXX could be an serial.issuance
468         proximity       NUMERIC,
469         CONSTRAINT copy_once_per_hold UNIQUE (hold,target_copy)
470 );
471 -- CREATE INDEX acm_hold_idx ON action.hold_copy_map (hold);
472 CREATE INDEX acm_copy_idx ON action.hold_copy_map (target_copy);
473
474 CREATE TABLE action.transit_copy (
475         id                      SERIAL                          PRIMARY KEY,
476         source_send_time        TIMESTAMP WITH TIME ZONE,
477         dest_recv_time          TIMESTAMP WITH TIME ZONE,
478         target_copy             BIGINT                          NOT NULL, -- REFERENCES asset.copy (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, -- XXX could be an serial.issuance
479         source                  INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
480         dest                    INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
481         prev_hop                INT                             REFERENCES action.transit_copy (id) DEFERRABLE INITIALLY DEFERRED,
482         copy_status             INT                             NOT NULL REFERENCES config.copy_status (id) DEFERRABLE INITIALLY DEFERRED,
483         persistant_transfer     BOOL                            NOT NULL DEFAULT FALSE,
484         prev_dest       INT                             REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED
485 );
486 CREATE INDEX active_transit_dest_idx ON "action".transit_copy (dest); 
487 CREATE INDEX active_transit_source_idx ON "action".transit_copy (source);
488 CREATE INDEX active_transit_cp_idx ON "action".transit_copy (target_copy);
489
490
491 CREATE TABLE action.hold_transit_copy (
492         hold    INT     REFERENCES action.hold_request (id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED
493 ) INHERITS (action.transit_copy);
494 ALTER TABLE action.hold_transit_copy ADD PRIMARY KEY (id);
495 -- 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
496 CREATE INDEX active_hold_transit_dest_idx ON "action".hold_transit_copy (dest);
497 CREATE INDEX active_hold_transit_source_idx ON "action".hold_transit_copy (source);
498 CREATE INDEX active_hold_transit_cp_idx ON "action".hold_transit_copy (target_copy);
499 CREATE INDEX hold_transit_copy_hold_idx on action.hold_transit_copy (hold);
500
501
502 CREATE TABLE action.unfulfilled_hold_list (
503         id              BIGSERIAL                       PRIMARY KEY,
504         current_copy    BIGINT                          NOT NULL,
505         hold            INT                             NOT NULL,
506         circ_lib        INT                             NOT NULL,
507         fail_time       TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW()
508 );
509 CREATE INDEX uhr_hold_idx ON action.unfulfilled_hold_list (hold);
510
511 CREATE OR REPLACE VIEW action.unfulfilled_hold_loops AS
512     SELECT  u.hold,
513             c.circ_lib,
514             count(*)
515       FROM  action.unfulfilled_hold_list u
516             JOIN asset.copy c ON (c.id = u.current_copy)
517       GROUP BY 1,2;
518
519 CREATE OR REPLACE VIEW action.unfulfilled_hold_min_loop AS
520     SELECT  hold,
521             min(count)
522       FROM  action.unfulfilled_hold_loops
523       GROUP BY 1;
524
525 CREATE OR REPLACE VIEW action.unfulfilled_hold_innermost_loop AS
526     SELECT  DISTINCT l.*
527       FROM  action.unfulfilled_hold_loops l
528             JOIN action.unfulfilled_hold_min_loop m USING (hold)
529       WHERE l.count = m.min;
530
531 CREATE VIEW action.unfulfilled_hold_max_loop AS
532     SELECT  hold,
533             max(count) AS max
534       FROM  action.unfulfilled_hold_loops
535       GROUP BY 1;
536
537
538 CREATE TABLE action.aged_hold_request (
539     usr_post_code               TEXT,
540     usr_home_ou         INT     NOT NULL,
541     usr_profile         INT     NOT NULL,
542     usr_birth_year              INT,
543     staff_placed        BOOLEAN NOT NULL,
544     LIKE action.hold_request
545 );
546 ALTER TABLE action.aged_hold_request
547       ADD PRIMARY KEY (id),
548       DROP COLUMN usr,
549       DROP COLUMN requestor,
550       DROP COLUMN sms_carrier,
551       ALTER COLUMN phone_notify TYPE BOOLEAN
552             USING CASE WHEN phone_notify IS NULL OR phone_notify = '' THEN FALSE ELSE TRUE END,
553       ALTER COLUMN sms_notify TYPE BOOLEAN
554             USING CASE WHEN sms_notify IS NULL OR sms_notify = '' THEN FALSE ELSE TRUE END,
555       ALTER COLUMN phone_notify SET NOT NULL,
556       ALTER COLUMN sms_notify SET NOT NULL;
557 CREATE INDEX aged_hold_request_target_idx ON action.aged_hold_request (target);
558 CREATE INDEX aged_hold_request_pickup_lib_idx ON action.aged_hold_request (pickup_lib);
559 CREATE INDEX aged_hold_request_current_copy_idx ON action.aged_hold_request (current_copy);
560 CREATE INDEX aged_hold_request_fulfillment_staff_idx ON action.aged_hold_request ( fulfillment_staff );
561
562 CREATE OR REPLACE VIEW action.all_hold_request AS
563     SELECT DISTINCT
564            COALESCE(a.post_code, b.post_code) AS usr_post_code,
565            p.home_ou AS usr_home_ou,
566            p.profile AS usr_profile,
567            EXTRACT(YEAR FROM p.dob)::INT AS usr_birth_year,
568            CAST(ahr.requestor <> ahr.usr AS BOOLEAN) AS staff_placed,
569            ahr.id,
570            ahr.request_time,
571            ahr.capture_time,
572            ahr.fulfillment_time,
573            ahr.checkin_time,
574            ahr.return_time,
575            ahr.prev_check_time,
576            ahr.expire_time,
577            ahr.cancel_time,
578            ahr.cancel_cause,
579            ahr.cancel_note,
580            ahr.target,
581            ahr.current_copy,
582            ahr.fulfillment_staff,
583            ahr.fulfillment_lib,
584            ahr.request_lib,
585            ahr.selection_ou,
586            ahr.selection_depth,
587            ahr.pickup_lib,
588            ahr.hold_type,
589            ahr.holdable_formats,
590            CASE
591            WHEN ahr.phone_notify IS NULL THEN FALSE
592            WHEN ahr.phone_notify = '' THEN FALSE
593            ELSE TRUE
594            END AS phone_notify,
595            ahr.email_notify,
596            CASE
597            WHEN ahr.sms_notify IS NULL THEN FALSE
598            WHEN ahr.sms_notify = '' THEN FALSE
599            ELSE TRUE
600            END AS sms_notify,
601            ahr.frozen,
602            ahr.thaw_date,
603            ahr.shelf_time,
604            ahr.cut_in_line,
605            ahr.mint_condition,
606            ahr.shelf_expire_time,
607            ahr.current_shelf_lib,
608            ahr.behind_desk
609     FROM action.hold_request ahr
610          JOIN actor.usr p ON (ahr.usr = p.id)
611          LEFT JOIN actor.usr_address a ON (p.mailing_address = a.id)
612          LEFT JOIN actor.usr_address b ON (p.billing_address = b.id)
613     UNION ALL
614     SELECT 
615            usr_post_code,
616            usr_home_ou,
617            usr_profile,
618            usr_birth_year,
619            staff_placed,
620            id,
621            request_time,
622            capture_time,
623            fulfillment_time,
624            checkin_time,
625            return_time,
626            prev_check_time,
627            expire_time,
628            cancel_time,
629            cancel_cause,
630            cancel_note,
631            target,
632            current_copy,
633            fulfillment_staff,
634            fulfillment_lib,
635            request_lib,
636            selection_ou,
637            selection_depth,
638            pickup_lib,
639            hold_type,
640            holdable_formats,
641            phone_notify,
642            email_notify,
643            sms_notify,
644            frozen,
645            thaw_date,
646            shelf_time,
647            cut_in_line,
648            mint_condition,
649            shelf_expire_time,
650            current_shelf_lib,
651            behind_desk
652     FROM action.aged_hold_request;
653
654 CREATE OR REPLACE FUNCTION action.age_hold_on_delete () RETURNS TRIGGER AS $$
655 DECLARE
656 BEGIN
657     -- Archive a copy of the old row to action.aged_hold_request
658
659     INSERT INTO action.aged_hold_request
660            (usr_post_code,
661             usr_home_ou,
662             usr_profile,
663             usr_birth_year,
664             staff_placed,
665             id,
666             request_time,
667             capture_time,
668             fulfillment_time,
669             checkin_time,
670             return_time,
671             prev_check_time,
672             expire_time,
673             cancel_time,
674             cancel_cause,
675             cancel_note,
676             target,
677             current_copy,
678             fulfillment_staff,
679             fulfillment_lib,
680             request_lib,
681             selection_ou,
682             selection_depth,
683             pickup_lib,
684             hold_type,
685             holdable_formats,
686             phone_notify,
687             email_notify,
688             sms_notify,
689             frozen,
690             thaw_date,
691             shelf_time,
692             cut_in_line,
693             mint_condition,
694             shelf_expire_time,
695             current_shelf_lib,
696             behind_desk)
697       SELECT 
698            usr_post_code,
699            usr_home_ou,
700            usr_profile,
701            usr_birth_year,
702            staff_placed,
703            id,
704            request_time,
705            capture_time,
706            fulfillment_time,
707            checkin_time,
708            return_time,
709            prev_check_time,
710            expire_time,
711            cancel_time,
712            cancel_cause,
713            cancel_note,
714            target,
715            current_copy,
716            fulfillment_staff,
717            fulfillment_lib,
718            request_lib,
719            selection_ou,
720            selection_depth,
721            pickup_lib,
722            hold_type,
723            holdable_formats,
724            phone_notify,
725            email_notify,
726            sms_notify,
727            frozen,
728            thaw_date,
729            shelf_time,
730            cut_in_line,
731            mint_condition,
732            shelf_expire_time,
733            current_shelf_lib,
734            behind_desk
735         FROM action.all_hold_request WHERE id = OLD.id;
736
737     RETURN OLD;
738 END;
739 $$ LANGUAGE 'plpgsql';
740
741 CREATE TRIGGER action_hold_request_aging_tgr
742         BEFORE DELETE ON action.hold_request
743         FOR EACH ROW
744         EXECUTE PROCEDURE action.age_hold_on_delete ();
745
746 CREATE TABLE action.fieldset (
747     id              SERIAL          PRIMARY KEY,
748     owner           INT             NOT NULL REFERENCES actor.usr (id)
749                                     DEFERRABLE INITIALLY DEFERRED,
750         owning_lib      INT             NOT NULL REFERENCES actor.org_unit (id)
751                                     DEFERRABLE INITIALLY DEFERRED,
752         status          TEXT            NOT NULL
753                                         CONSTRAINT valid_status CHECK ( status in
754                                                                         ( 'PENDING', 'APPLIED', 'ERROR' )),
755     creation_time   TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
756     scheduled_time  TIMESTAMPTZ,
757     applied_time    TIMESTAMPTZ,
758     classname       TEXT            NOT NULL, -- an IDL class name
759     name            TEXT            NOT NULL,
760     stored_query    INT             REFERENCES query.stored_query (id)
761                                     DEFERRABLE INITIALLY DEFERRED,
762     pkey_value      TEXT,
763         CONSTRAINT lib_name_unique UNIQUE (owning_lib, name),
764     CONSTRAINT fieldset_one_or_the_other CHECK (
765         (stored_query IS NOT NULL AND pkey_value IS NULL) OR
766         (pkey_value IS NOT NULL AND stored_query IS NULL)
767     )
768         -- the CHECK constraint means we can update the fields for a single
769         -- row without all the extra overhead involved in a query
770 );
771
772 CREATE INDEX action_fieldset_sched_time_idx ON action.fieldset( scheduled_time );
773 CREATE INDEX action_owner_idx               ON action.fieldset( owner );
774
775
776 CREATE TABLE action.fieldset_col_val (
777     id              SERIAL  PRIMARY KEY,
778     fieldset        INT     NOT NULL REFERENCES action.fieldset
779                                          ON DELETE CASCADE
780                                          DEFERRABLE INITIALLY DEFERRED,
781     col             TEXT    NOT NULL,  -- "field" from the idl ... the column on the table
782     val             TEXT,              -- value for the column ... NULL means, well, NULL
783     CONSTRAINT fieldset_col_once_per_set UNIQUE (fieldset, col)
784 );
785
786
787 -- represents a circ chain summary
788 CREATE TYPE action.circ_chain_summary AS (
789     num_circs INTEGER,
790     start_time TIMESTAMP WITH TIME ZONE,
791     checkout_workstation TEXT,
792     last_renewal_time TIMESTAMP WITH TIME ZONE, -- NULL if no renewals
793     last_stop_fines TEXT,
794     last_stop_fines_time TIMESTAMP WITH TIME ZONE,
795     last_renewal_workstation TEXT, -- NULL if no renewals
796     last_checkin_workstation TEXT,
797     last_checkin_time TIMESTAMP WITH TIME ZONE,
798     last_checkin_scan_time TIMESTAMP WITH TIME ZONE
799 );
800
801
802 CREATE OR REPLACE FUNCTION action.circ_chain ( ctx_circ_id BIGINT ) RETURNS SETOF action.circulation AS $$
803 DECLARE
804     tmp_circ action.circulation%ROWTYPE;
805     circ_0 action.circulation%ROWTYPE;
806 BEGIN
807
808     SELECT INTO tmp_circ * FROM action.circulation WHERE id = ctx_circ_id;
809
810     IF tmp_circ IS NULL THEN
811         RETURN NEXT tmp_circ;
812     END IF;
813     circ_0 := tmp_circ;
814
815     -- find the front of the chain
816     WHILE TRUE LOOP
817         SELECT INTO tmp_circ * FROM action.circulation WHERE id = tmp_circ.parent_circ;
818         IF tmp_circ IS NULL THEN
819             EXIT;
820         END IF;
821         circ_0 := tmp_circ;
822     END LOOP;
823
824     -- now send the circs to the caller, oldest to newest
825     tmp_circ := circ_0;
826     WHILE TRUE LOOP
827         IF tmp_circ IS NULL THEN
828             EXIT;
829         END IF;
830         RETURN NEXT tmp_circ;
831         SELECT INTO tmp_circ * FROM action.circulation WHERE parent_circ = tmp_circ.id;
832     END LOOP;
833
834 END;
835 $$ LANGUAGE 'plpgsql';
836
837 CREATE OR REPLACE FUNCTION action.summarize_circ_chain ( ctx_circ_id BIGINT ) RETURNS action.circ_chain_summary AS $$
838
839 DECLARE
840
841     -- first circ in the chain
842     circ_0 action.circulation%ROWTYPE;
843
844     -- last circ in the chain
845     circ_n action.circulation%ROWTYPE;
846
847     -- circ chain under construction
848     chain action.circ_chain_summary;
849     tmp_circ action.circulation%ROWTYPE;
850
851 BEGIN
852     
853     chain.num_circs := 0;
854     FOR tmp_circ IN SELECT * FROM action.circ_chain(ctx_circ_id) LOOP
855
856         IF chain.num_circs = 0 THEN
857             circ_0 := tmp_circ;
858         END IF;
859
860         chain.num_circs := chain.num_circs + 1;
861         circ_n := tmp_circ;
862     END LOOP;
863
864     chain.start_time := circ_0.xact_start;
865     chain.last_stop_fines := circ_n.stop_fines;
866     chain.last_stop_fines_time := circ_n.stop_fines_time;
867     chain.last_checkin_time := circ_n.checkin_time;
868     chain.last_checkin_scan_time := circ_n.checkin_scan_time;
869     SELECT INTO chain.checkout_workstation name FROM actor.workstation WHERE id = circ_0.workstation;
870     SELECT INTO chain.last_checkin_workstation name FROM actor.workstation WHERE id = circ_n.checkin_workstation;
871
872     IF chain.num_circs > 1 THEN
873         chain.last_renewal_time := circ_n.xact_start;
874         SELECT INTO chain.last_renewal_workstation name FROM actor.workstation WHERE id = circ_n.workstation;
875     END IF;
876
877     RETURN chain;
878
879 END;
880 $$ LANGUAGE 'plpgsql';
881
882 -- Return the list of circ chain heads in xact_start order that the user has chosen to "retain"
883 CREATE OR REPLACE FUNCTION action.usr_visible_circs (usr_id INT) RETURNS SETOF action.circulation AS $func$
884 DECLARE
885     c               action.circulation%ROWTYPE;
886     view_age        INTERVAL;
887     usr_view_age    actor.usr_setting%ROWTYPE;
888     usr_view_start  actor.usr_setting%ROWTYPE;
889 BEGIN
890     SELECT * INTO usr_view_age FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.circ.retention_age';
891     SELECT * INTO usr_view_start FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.circ.retention_start';
892
893     IF usr_view_age.value IS NOT NULL AND usr_view_start.value IS NOT NULL THEN
894         -- User opted in and supplied a retention age
895         IF oils_json_to_text(usr_view_age.value)::INTERVAL > AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ) THEN
896             view_age := AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ);
897         ELSE
898             view_age := oils_json_to_text(usr_view_age.value)::INTERVAL;
899         END IF;
900     ELSIF usr_view_start.value IS NOT NULL THEN
901         -- User opted in
902         view_age := AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ);
903     ELSE
904         -- User did not opt in
905         RETURN;
906     END IF;
907
908     FOR c IN
909         SELECT  *
910           FROM  action.circulation
911           WHERE usr = usr_id
912                 AND parent_circ IS NULL
913                 AND xact_start > NOW() - view_age
914           ORDER BY xact_start DESC
915     LOOP
916         RETURN NEXT c;
917     END LOOP;
918
919     RETURN;
920 END;
921 $func$ LANGUAGE PLPGSQL;
922
923 CREATE OR REPLACE FUNCTION action.usr_visible_circ_copies( INTEGER ) RETURNS SETOF BIGINT AS $$
924     SELECT DISTINCT(target_copy) FROM action.usr_visible_circs($1)
925 $$ LANGUAGE SQL ROWS 10;
926
927 CREATE OR REPLACE FUNCTION action.usr_visible_holds (usr_id INT) RETURNS SETOF action.hold_request AS $func$
928 DECLARE
929     h               action.hold_request%ROWTYPE;
930     view_age        INTERVAL;
931     view_count      INT;
932     usr_view_count  actor.usr_setting%ROWTYPE;
933     usr_view_age    actor.usr_setting%ROWTYPE;
934     usr_view_start  actor.usr_setting%ROWTYPE;
935 BEGIN
936     SELECT * INTO usr_view_count FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.hold.retention_count';
937     SELECT * INTO usr_view_age FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.hold.retention_age';
938     SELECT * INTO usr_view_start FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.hold.retention_start';
939
940     FOR h IN
941         SELECT  *
942           FROM  action.hold_request
943           WHERE usr = usr_id
944                 AND fulfillment_time IS NULL
945                 AND cancel_time IS NULL
946           ORDER BY request_time DESC
947     LOOP
948         RETURN NEXT h;
949     END LOOP;
950
951     IF usr_view_start.value IS NULL THEN
952         RETURN;
953     END IF;
954
955     IF usr_view_age.value IS NOT NULL THEN
956         -- User opted in and supplied a retention age
957         IF oils_json_to_text(usr_view_age.value)::INTERVAL > AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ) THEN
958             view_age := AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ);
959         ELSE
960             view_age := oils_json_to_text(usr_view_age.value)::INTERVAL;
961         END IF;
962     ELSE
963         -- User opted in
964         view_age := AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ);
965     END IF;
966
967     IF usr_view_count.value IS NOT NULL THEN
968         view_count := oils_json_to_text(usr_view_count.value)::INT;
969     ELSE
970         view_count := 1000;
971     END IF;
972
973     -- show some fulfilled/canceled holds
974     FOR h IN
975         SELECT  *
976           FROM  action.hold_request
977           WHERE usr = usr_id
978                 AND ( fulfillment_time IS NOT NULL OR cancel_time IS NOT NULL )
979                 AND COALESCE(fulfillment_time, cancel_time) > NOW() - view_age
980           ORDER BY COALESCE(fulfillment_time, cancel_time) DESC
981           LIMIT view_count
982     LOOP
983         RETURN NEXT h;
984     END LOOP;
985
986     RETURN;
987 END;
988 $func$ LANGUAGE PLPGSQL;
989
990 CREATE OR REPLACE FUNCTION action.purge_circulations () RETURNS INT AS $func$
991 DECLARE
992     usr_keep_age    actor.usr_setting%ROWTYPE;
993     usr_keep_start  actor.usr_setting%ROWTYPE;
994     org_keep_age    INTERVAL;
995     org_use_last    BOOL = false;
996     org_age_is_min  BOOL = false;
997     org_keep_count  INT;
998
999     keep_age        INTERVAL;
1000
1001     target_acp      RECORD;
1002     circ_chain_head action.circulation%ROWTYPE;
1003     circ_chain_tail action.circulation%ROWTYPE;
1004
1005     count_purged    INT;
1006     num_incomplete  INT;
1007
1008     last_finished   TIMESTAMP WITH TIME ZONE;
1009 BEGIN
1010
1011     count_purged := 0;
1012
1013     SELECT value::INTERVAL INTO org_keep_age FROM config.global_flag WHERE name = 'history.circ.retention_age' AND enabled;
1014
1015     SELECT value::INT INTO org_keep_count FROM config.global_flag WHERE name = 'history.circ.retention_count' AND enabled;
1016     IF org_keep_count IS NULL THEN
1017         RETURN count_purged; -- Gimme a count to keep, or I keep them all, forever
1018     END IF;
1019
1020     SELECT enabled INTO org_use_last FROM config.global_flag WHERE name = 'history.circ.retention_uses_last_finished';
1021     SELECT enabled INTO org_age_is_min FROM config.global_flag WHERE name = 'history.circ.retention_age_is_min';
1022
1023     -- First, find copies with more than keep_count non-renewal circs
1024     FOR target_acp IN
1025         SELECT  target_copy,
1026                 COUNT(*) AS total_real_circs
1027           FROM  action.circulation
1028           WHERE parent_circ IS NULL
1029                 AND xact_finish IS NOT NULL
1030           GROUP BY target_copy
1031           HAVING COUNT(*) > org_keep_count
1032     LOOP
1033         -- And, for those, select circs that are finished and older than keep_age
1034         FOR circ_chain_head IN
1035             -- For reference, the subquery uses a window function to order the circs newest to oldest and number them
1036             -- The outer query then uses that information to skip the most recent set the library wants to keep
1037             -- End result is we don't care what order they come out in, as they are all potentials for deletion.
1038             SELECT ac.* FROM action.circulation ac JOIN (
1039               SELECT  rank() OVER (ORDER BY xact_start DESC), ac.id
1040                 FROM  action.circulation ac
1041                 WHERE ac.target_copy = target_acp.target_copy
1042                   AND ac.parent_circ IS NULL
1043                 ORDER BY ac.xact_start ) ranked USING (id)
1044                 WHERE ranked.rank > org_keep_count
1045         LOOP
1046
1047             SELECT * INTO circ_chain_tail FROM action.circ_chain(circ_chain_head.id) ORDER BY xact_start DESC LIMIT 1;
1048             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);
1049             CONTINUE WHEN circ_chain_tail.xact_finish IS NULL OR num_incomplete > 0;
1050
1051             IF NOT org_use_last THEN
1052                 last_finished := circ_chain_tail.xact_finish;
1053             END IF;
1054
1055             -- Now get the user settings, if any, to block purging if the user wants to keep more circs
1056             usr_keep_age.value := NULL;
1057             SELECT * INTO usr_keep_age FROM actor.usr_setting WHERE usr = circ_chain_head.usr AND name = 'history.circ.retention_age';
1058
1059             usr_keep_start.value := NULL;
1060             SELECT * INTO usr_keep_start FROM actor.usr_setting WHERE usr = circ_chain_head.usr AND name = 'history.circ.retention_start';
1061
1062             IF usr_keep_age.value IS NOT NULL AND usr_keep_start.value IS NOT NULL THEN
1063                 IF oils_json_to_text(usr_keep_age.value)::INTERVAL > AGE(NOW(), oils_json_to_text(usr_keep_start.value)::TIMESTAMPTZ) THEN
1064                     keep_age := AGE(NOW(), oils_json_to_text(usr_keep_start.value)::TIMESTAMPTZ);
1065                 ELSE
1066                     keep_age := oils_json_to_text(usr_keep_age.value)::INTERVAL;
1067                 END IF;
1068             ELSIF usr_keep_start.value IS NOT NULL THEN
1069                 keep_age := AGE(NOW(), oils_json_to_text(usr_keep_start.value)::TIMESTAMPTZ);
1070             ELSE
1071                 keep_age := COALESCE( org_keep_age, '2000 years'::INTERVAL );
1072             END IF;
1073
1074             IF org_age_is_min THEN
1075                 keep_age := GREATEST( keep_age, org_keep_age );
1076             END IF;
1077
1078             CONTINUE WHEN AGE(NOW(), last_finished) < keep_age;
1079
1080             -- We've passed the purging tests, purge the circ chain starting at the end
1081             -- A trigger should auto-purge the rest of the chain.
1082             DELETE FROM action.circulation WHERE id = circ_chain_tail.id;
1083
1084             count_purged := count_purged + 1;
1085
1086         END LOOP;
1087     END LOOP;
1088
1089     return count_purged;
1090 END;
1091 $func$ LANGUAGE PLPGSQL;
1092
1093 CREATE OR REPLACE FUNCTION action.purge_holds() RETURNS INT AS $func$
1094 DECLARE
1095   current_hold RECORD;
1096   purged_holds INT;
1097   cgf_d INTERVAL;
1098   cgf_f INTERVAL;
1099   cgf_c INTERVAL;
1100   prev_usr INT;
1101   user_start TIMESTAMPTZ;
1102   user_age INTERVAL;
1103   user_count INT;
1104 BEGIN
1105   purged_holds := 0;
1106   SELECT INTO cgf_d value::INTERVAL FROM config.global_flag WHERE name = 'history.hold.retention_age' AND enabled;
1107   SELECT INTO cgf_f value::INTERVAL FROM config.global_flag WHERE name = 'history.hold.retention_age_fulfilled' AND enabled;
1108   SELECT INTO cgf_c value::INTERVAL FROM config.global_flag WHERE name = 'history.hold.retention_age_canceled' AND enabled;
1109   FOR current_hold IN
1110     SELECT
1111       rank() OVER (PARTITION BY usr ORDER BY COALESCE(fulfillment_time, cancel_time) DESC),
1112       cgf_cs.value::INTERVAL as cgf_cs,
1113       ahr.*
1114     FROM
1115       action.hold_request ahr
1116       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)
1117     WHERE
1118       (fulfillment_time IS NOT NULL OR cancel_time IS NOT NULL)
1119   LOOP
1120     IF prev_usr IS NULL OR prev_usr != current_hold.usr THEN
1121       prev_usr := current_hold.usr;
1122       SELECT INTO user_start oils_json_to_text(value)::TIMESTAMPTZ FROM actor.usr_setting WHERE usr = prev_usr AND name = 'history.hold.retention_start';
1123       SELECT INTO user_age oils_json_to_text(value)::INTERVAL FROM actor.usr_setting WHERE usr = prev_usr AND name = 'history.hold.retention_age';
1124       SELECT INTO user_count oils_json_to_text(value)::INT FROM actor.usr_setting WHERE usr = prev_usr AND name = 'history.hold.retention_count';
1125       IF user_start IS NOT NULL THEN
1126         user_age := LEAST(user_age, AGE(NOW(), user_start));
1127       END IF;
1128       IF user_count IS NULL THEN
1129         user_count := 1000; -- Assumption based on the user visible holds routine
1130       END IF;
1131     END IF;
1132     -- Library keep age trumps user keep anything, for purposes of being able to hold on to things when staff canceled and such.
1133     IF current_hold.fulfillment_time IS NOT NULL AND current_hold.fulfillment_time > NOW() - COALESCE(cgf_f, cgf_d) THEN
1134       CONTINUE;
1135     END IF;
1136     IF current_hold.cancel_time IS NOT NULL AND current_hold.cancel_time > NOW() - COALESCE(current_hold.cgf_cs, cgf_c, cgf_d) THEN
1137       CONTINUE;
1138     END IF;
1139
1140     -- User keep age needs combining with count. If too old AND within the count, keep!
1141     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
1142       CONTINUE;
1143     END IF;
1144
1145     -- All checks should have passed, delete!
1146     DELETE FROM action.hold_request WHERE id = current_hold.id;
1147     purged_holds := purged_holds + 1;
1148   END LOOP;
1149   RETURN purged_holds;
1150 END;
1151 $func$ LANGUAGE plpgsql;
1152
1153 CREATE OR REPLACE FUNCTION action.apply_fieldset(
1154         fieldset_id IN INT,        -- id from action.fieldset
1155         table_name  IN TEXT,       -- table to be updated
1156         pkey_name   IN TEXT,       -- name of primary key column in that table
1157         query       IN TEXT        -- query constructed by qstore (for query-based
1158                                    --    fieldsets only; otherwise null
1159 )
1160 RETURNS TEXT AS $$
1161 DECLARE
1162         statement TEXT;
1163         fs_status TEXT;
1164         fs_pkey_value TEXT;
1165         fs_query TEXT;
1166         sep CHAR;
1167         status_code TEXT;
1168         msg TEXT;
1169         update_count INT;
1170         cv RECORD;
1171 BEGIN
1172         -- Sanity checks
1173         IF fieldset_id IS NULL THEN
1174                 RETURN 'Fieldset ID parameter is NULL';
1175         END IF;
1176         IF table_name IS NULL THEN
1177                 RETURN 'Table name parameter is NULL';
1178         END IF;
1179         IF pkey_name IS NULL THEN
1180                 RETURN 'Primary key name parameter is NULL';
1181         END IF;
1182         --
1183         statement := 'UPDATE ' || table_name || ' SET';
1184         --
1185         SELECT
1186                 status,
1187                 quote_literal( pkey_value )
1188         INTO
1189                 fs_status,
1190                 fs_pkey_value
1191         FROM
1192                 action.fieldset
1193         WHERE
1194                 id = fieldset_id;
1195         --
1196         IF fs_status IS NULL THEN
1197                 RETURN 'No fieldset found for id = ' || fieldset_id;
1198         ELSIF fs_status = 'APPLIED' THEN
1199                 RETURN 'Fieldset ' || fieldset_id || ' has already been applied';
1200         END IF;
1201         --
1202         sep := '';
1203         FOR cv IN
1204                 SELECT  col,
1205                                 val
1206                 FROM    action.fieldset_col_val
1207                 WHERE   fieldset = fieldset_id
1208         LOOP
1209                 statement := statement || sep || ' ' || cv.col
1210                                          || ' = ' || coalesce( quote_literal( cv.val ), 'NULL' );
1211                 sep := ',';
1212         END LOOP;
1213         --
1214         IF sep = '' THEN
1215                 RETURN 'Fieldset ' || fieldset_id || ' has no column values defined';
1216         END IF;
1217         --
1218         -- Add the WHERE clause.  This differs according to whether it's a
1219         -- single-row fieldset or a query-based fieldset.
1220         --
1221         IF query IS NULL        AND fs_pkey_value IS NULL THEN
1222                 RETURN 'Incomplete fieldset: neither a primary key nor a query available';
1223         ELSIF query IS NOT NULL AND fs_pkey_value IS NULL THEN
1224             fs_query := rtrim( query, ';' );
1225             statement := statement || ' WHERE ' || pkey_name || ' IN ( '
1226                          || fs_query || ' );';
1227         ELSIF query IS NULL     AND fs_pkey_value IS NOT NULL THEN
1228                 statement := statement || ' WHERE ' || pkey_name || ' = '
1229                                      || fs_pkey_value || ';';
1230         ELSE  -- both are not null
1231                 RETURN 'Ambiguous fieldset: both a primary key and a query provided';
1232         END IF;
1233         --
1234         -- Execute the update
1235         --
1236         BEGIN
1237                 EXECUTE statement;
1238                 GET DIAGNOSTICS update_count = ROW_COUNT;
1239                 --
1240                 IF UPDATE_COUNT > 0 THEN
1241                         status_code := 'APPLIED';
1242                         msg := NULL;
1243                 ELSE
1244                         status_code := 'ERROR';
1245                         msg := 'No eligible rows found for fieldset ' || fieldset_id;
1246         END IF;
1247         EXCEPTION WHEN OTHERS THEN
1248                 status_code := 'ERROR';
1249                 msg := 'Unable to apply fieldset ' || fieldset_id
1250                            || ': ' || sqlerrm;
1251         END;
1252         --
1253         -- Update fieldset status
1254         --
1255         UPDATE action.fieldset
1256         SET status       = status_code,
1257             applied_time = now()
1258         WHERE id = fieldset_id;
1259         --
1260         RETURN msg;
1261 END;
1262 $$ LANGUAGE plpgsql;
1263
1264 COMMENT ON FUNCTION action.apply_fieldset( INT, TEXT, TEXT, TEXT ) IS $$
1265 Applies a specified fieldset, using a supplied table name and primary
1266 key name.  The query parameter should be non-null only for
1267 query-based fieldsets.
1268
1269 Returns NULL if successful, or an error message if not.
1270 $$;
1271
1272 CREATE OR REPLACE FUNCTION action.hold_copy_calculated_proximity(
1273     ahr_id INT,
1274     acp_id BIGINT,
1275     copy_context_ou INT DEFAULT NULL
1276     -- TODO maybe? hold_context_ou INT DEFAULT NULL.  This would optionally
1277     -- support an "ahprox" measurement: adjust prox between copy circ lib and
1278     -- hold request lib, but I'm unsure whether to use this theoretical
1279     -- argument only in the baseline calculation or later in the other
1280     -- queries in this function.
1281 ) RETURNS NUMERIC AS $f$
1282 DECLARE
1283     aoupa           actor.org_unit_proximity_adjustment%ROWTYPE;
1284     ahr             action.hold_request%ROWTYPE;
1285     acp             asset.copy%ROWTYPE;
1286     acn             asset.call_number%ROWTYPE;
1287     acl             asset.copy_location%ROWTYPE;
1288     baseline_prox   NUMERIC;
1289
1290     icl_list        INT[];
1291     iol_list        INT[];
1292     isl_list        INT[];
1293     hpl_list        INT[];
1294     hrl_list        INT[];
1295
1296 BEGIN
1297
1298     SELECT * INTO ahr FROM action.hold_request WHERE id = ahr_id;
1299     SELECT * INTO acp FROM asset.copy WHERE id = acp_id;
1300     SELECT * INTO acn FROM asset.call_number WHERE id = acp.call_number;
1301     SELECT * INTO acl FROM asset.copy_location WHERE id = acp.location;
1302
1303     IF copy_context_ou IS NULL THEN
1304         copy_context_ou := acp.circ_lib;
1305     END IF;
1306
1307     -- First, gather the baseline proximity of "here" to pickup lib
1308     SELECT prox INTO baseline_prox FROM actor.org_unit_proximity WHERE from_org = copy_context_ou AND to_org = ahr.pickup_lib;
1309
1310     -- Find any absolute adjustments, and set the baseline prox to that
1311     SELECT  adj.* INTO aoupa
1312       FROM  actor.org_unit_proximity_adjustment adj
1313             LEFT JOIN actor.org_unit_ancestors_distance(copy_context_ou) acp_cl ON (acp_cl.id = adj.item_circ_lib)
1314             LEFT JOIN actor.org_unit_ancestors_distance(acn.owning_lib) acn_ol ON (acn_ol.id = adj.item_owning_lib)
1315             LEFT JOIN actor.org_unit_ancestors_distance(acl.owning_lib) acl_ol ON (acl_ol.id = adj.copy_location)
1316             LEFT JOIN actor.org_unit_ancestors_distance(ahr.pickup_lib) ahr_pl ON (ahr_pl.id = adj.hold_pickup_lib)
1317             LEFT JOIN actor.org_unit_ancestors_distance(ahr.request_lib) ahr_rl ON (ahr_rl.id = adj.hold_request_lib)
1318       WHERE (adj.circ_mod IS NULL OR adj.circ_mod = acp.circ_modifier) AND
1319             (adj.item_circ_lib IS NULL OR adj.item_circ_lib = acp_cl.id) AND
1320             (adj.item_owning_lib IS NULL OR adj.item_owning_lib = acn_ol.id) AND
1321             (adj.copy_location IS NULL OR adj.copy_location = acl_ol.id) AND
1322             (adj.hold_pickup_lib IS NULL OR adj.hold_pickup_lib = ahr_pl.id) AND
1323             (adj.hold_request_lib IS NULL OR adj.hold_request_lib = ahr_rl.id) AND
1324             absolute_adjustment AND
1325             COALESCE(acp_cl.id, acn_ol.id, acl_ol.id, ahr_pl.id, ahr_rl.id) IS NOT NULL
1326       ORDER BY
1327             COALESCE(acp_cl.distance,999)
1328                 + COALESCE(acn_ol.distance,999)
1329                 + COALESCE(acl_ol.distance,999)
1330                 + COALESCE(ahr_pl.distance,999)
1331                 + COALESCE(ahr_rl.distance,999),
1332             adj.pos
1333       LIMIT 1;
1334
1335     IF FOUND THEN
1336         baseline_prox := aoupa.prox_adjustment;
1337     END IF;
1338
1339     -- Now find any relative adjustments, and change the baseline prox based on them
1340     FOR aoupa IN
1341         SELECT  adj.* 
1342           FROM  actor.org_unit_proximity_adjustment adj
1343                 LEFT JOIN actor.org_unit_ancestors_distance(copy_context_ou) acp_cl ON (acp_cl.id = adj.item_circ_lib)
1344                 LEFT JOIN actor.org_unit_ancestors_distance(acn.owning_lib) acn_ol ON (acn_ol.id = adj.item_owning_lib)
1345                 LEFT JOIN actor.org_unit_ancestors_distance(acl.owning_lib) acl_ol ON (acn_ol.id = adj.copy_location)
1346                 LEFT JOIN actor.org_unit_ancestors_distance(ahr.pickup_lib) ahr_pl ON (ahr_pl.id = adj.hold_pickup_lib)
1347                 LEFT JOIN actor.org_unit_ancestors_distance(ahr.request_lib) ahr_rl ON (ahr_rl.id = adj.hold_request_lib)
1348           WHERE (adj.circ_mod IS NULL OR adj.circ_mod = acp.circ_modifier) AND
1349                 (adj.item_circ_lib IS NULL OR adj.item_circ_lib = acp_cl.id) AND
1350                 (adj.item_owning_lib IS NULL OR adj.item_owning_lib = acn_ol.id) AND
1351                 (adj.copy_location IS NULL OR adj.copy_location = acl_ol.id) AND
1352                 (adj.hold_pickup_lib IS NULL OR adj.hold_pickup_lib = ahr_pl.id) AND
1353                 (adj.hold_request_lib IS NULL OR adj.hold_request_lib = ahr_rl.id) AND
1354                 NOT absolute_adjustment AND
1355                 COALESCE(acp_cl.id, acn_ol.id, acl_ol.id, ahr_pl.id, ahr_rl.id) IS NOT NULL
1356     LOOP
1357         baseline_prox := baseline_prox + aoupa.prox_adjustment;
1358     END LOOP;
1359
1360     RETURN baseline_prox;
1361 END;
1362 $f$ LANGUAGE PLPGSQL;
1363
1364 CREATE OR REPLACE FUNCTION action.hold_copy_calculated_proximity_update () RETURNS TRIGGER AS $f$
1365 BEGIN
1366     NEW.proximity := action.hold_copy_calculated_proximity(NEW.hold,NEW.target_copy);
1367     RETURN NEW;
1368 END;
1369 $f$ LANGUAGE PLPGSQL;
1370
1371 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 ();
1372
1373 COMMIT;