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