1<?php
2// mailclasses.php -- HotCRP mail tool
3// Copyright (c) 2006-2018 Eddie Kohler; see LICENSE.
4
5class MailRecipients {
6    private $conf;
7    private $contact;
8    private $type;
9    private $sel = [];
10    private $selflags = [];
11    private $papersel;
12    public $newrev_since = 0;
13    public $error = false;
14
15    const F_ANYPC = 1;
16    const F_GROUP = 2;
17    const F_HIDE = 4;
18    const F_NOPAPERS = 8;
19    const F_SINCE = 16;
20
21    private function defsel($name, $description, $flags = 0) {
22        assert(!isset($this->sel[$name]));
23        $this->sel[$name] = $description;
24        $this->selflags[$name] = $flags;
25    }
26
27    function __construct($contact, $type, $papersel, $newrev_since) {
28        global $Now;
29        $this->conf = $contact->conf;
30        $this->contact = $contact;
31        assert(!!$contact->isPC);
32        $any_pcrev = $any_extrev = 0;
33        $any_newpcrev = $any_lead = $any_shepherd = 0;
34
35        if ($contact->is_manager()) {
36            $hide = !$this->conf->has_any_submitted();
37            $this->defsel("s", "Contact authors of submitted papers", $hide ? self::F_HIDE : 0);
38            $this->defsel("unsub", "Contact authors of unsubmitted papers");
39            $this->defsel("au", "All contact authors");
40
41            // map "somedec:no"/"somedec:yes" to real decisions
42            $result = $this->conf->qe("select outcome, count(*) from Paper where timeSubmitted>0 group by outcome");
43            $dec_pcount = edb_map($result);
44            $dec_tcount = array(0 => 0, 1 => 0, -1 => 0);
45            foreach ($dec_pcount as $dnum => $dcount)
46                $dec_tcount[$dnum > 0 ? 1 : ($dnum < 0 ? -1 : 0)] += $dcount;
47            if ($type == "somedec:no" || $type == "somedec:yes") {
48                $dmaxcount = -1;
49                foreach ($dec_pcount as $dnum => $dcount)
50                    if (($type[8] == "n" ? $dnum < 0 : $dnum > 0)
51                        && $dcount > $dmaxcount
52                        && ($dname = $this->conf->decision_name($dnum))) {
53                        $type = "dec:$dname";
54                        $dmaxcount = $dcount;
55                    }
56            }
57
58            $this->defsel("bydec_group", "Contact authors by decision", self::F_GROUP);
59            foreach ($this->conf->decision_map() as $dnum => $dname)
60                if ($dnum) {
61                    $k = "dec:$dname";
62                    $hide = !get($dec_pcount, $dnum);
63                    $this->defsel("dec:$dname", "Contact authors of " . htmlspecialchars($dname) . " papers", $hide ? self::F_HIDE : 0);
64                }
65            $this->defsel("dec:yes", "Contact authors of accept-class papers", $dec_tcount[1] == 0 ? self::F_HIDE : 0);
66            $this->defsel("dec:no", "Contact authors of reject-class papers", $dec_tcount[-1] == 0 ? self::F_HIDE : 0);
67            $this->defsel("dec:none", "Contact authors of undecided papers", $dec_tcount[0] == 0 || ($dec_tcount[1] == 0 && $dec_tcount[-1] == 0) ? self::F_HIDE : 0);
68            $this->defsel("dec:any", "Contact authors of decided papers", self::F_HIDE);
69            $this->defsel("bydec_group_end", null, self::F_GROUP);
70
71            $this->defsel("rev_group", "Reviewers", self::F_GROUP);
72
73            // XXX this exposes information about PC review assignments
74            // for conflicted papers to the chair; not worth worrying about
75            if (!$contact->privChair) {
76                $pids = [];
77                $result = $this->conf->qe("select paperId from Paper where managerContactId=?", $contact->contactId);
78                while ($result && ($row = edb_row($result)))
79                    $pids[] = (int) $row[0];
80                Dbl::free($result);
81                $pidw = empty($pids) ? "false" : "paperId in (" . join(",", $pids) . ")";
82            } else
83                $pidw = "true";
84            $row = $this->conf->fetch_first_row("select
85                exists (select * from PaperReview where reviewType>=" . REVIEW_PC . " and $pidw),
86                exists (select * from PaperReview where reviewType<" . REVIEW_PC . "  and $pidw),
87                exists (select * from PaperReview where reviewType>=" . REVIEW_PC . " and reviewSubmitted is null and reviewNeedsSubmit!=0 and timeRequested>timeRequestNotified and $pidw),
88                exists (select * from Paper where timeSubmitted>0 and leadContactId!=0 and $pidw),
89                exists (select * from Paper where timeSubmitted>0 and shepherdContactId!=0 and $pidw)");
90            list($any_pcrev, $any_extrev, $any_newpcrev, $any_lead, $any_shepherd) = $row;
91
92            $hide = $any_pcrev || $any_extrev ? 0 : self::F_HIDE;
93            $this->defsel("rev", "Reviewers", $hide);
94            $this->defsel("crev", "Reviewers with complete reviews", $hide);
95            $this->defsel("uncrev", "Reviewers with incomplete reviews", $hide);
96            $this->defsel("allcrev", "Reviewers with no incomplete reviews", $hide);
97
98            $hide = $any_pcrev ? 0 : self::F_HIDE;
99            $this->defsel("pcrev", "PC reviewers", $hide);
100            $this->defsel("uncpcrev", "PC reviewers with incomplete reviews", $hide);
101            $this->defsel("newpcrev", "PC reviewers with new review assignments", ($any_newpcrev && $any_pcrev ? 0 : self::F_HIDE) | self::F_SINCE);
102
103            $hide = $any_extrev ? 0 : self::F_HIDE;
104            $this->defsel("extrev", "External reviewers", $hide);
105            $this->defsel("uncextrev", "External reviewers with incomplete reviews", $hide);
106            $this->defsel("rev_group_end", null, self::F_GROUP);
107        }
108
109        $hide = !$this->contact->is_requester();
110        $this->defsel("myextrev", "Your requested reviewers", self::F_ANYPC | ($hide ? self::F_HIDE : 0));
111        $this->defsel("uncmyextrev", "Your requested reviewers with incomplete reviews", self::F_ANYPC | ($hide ? self::F_HIDE : 0));
112
113        if ($contact->is_manager()) {
114            $this->defsel("lead", "Discussion leads", $any_lead ? 0 : self::F_HIDE);
115            $this->defsel("shepherd", "Shepherds", $any_shepherd ? 0 : self::F_HIDE);
116        }
117
118        $this->defsel("pc_group", "Program committee", self::F_GROUP);
119        $selcount = count($this->sel);
120        $this->defsel("pc", "Program committee", self::F_ANYPC | self::F_NOPAPERS);
121        foreach ($this->conf->pc_tags() as $t)
122            if ($t != "pc")
123                $this->defsel("pc:$t", "#$t program committee", self::F_ANYPC | self::F_NOPAPERS);
124        if (count($this->sel) == $selcount + 1)
125            unset($this->sel["pc_group"]);
126        else
127            $this->defsel("pc_group_end", null, self::F_GROUP);
128
129        if ($contact->privChair)
130            $this->defsel("all", "All users", self::F_NOPAPERS);
131
132        if (isset($this->sel[$type])
133            && !($this->selflags[$type] & self::F_GROUP))
134            $this->type = $type;
135        else if ($type == "myuncextrev" && isset($this->sel["uncmyextrev"]))
136            $this->type = "uncmyextrev";
137        else
138            $this->type = key($this->sel);
139
140        $this->papersel = $papersel;
141
142        if ($this->type == "newpcrev") {
143            $t = trim((string) $newrev_since);
144            if (preg_match(',\A(?:|n/a|\(?all\)?|0)\z,i', $t))
145                $this->newrev_since = 0;
146            else if (($this->newrev_since = $this->conf->parse_time($t)) !== false) {
147                if ($this->newrev_since > $Now)
148                    $this->conf->warnMsg("That time is in the future.");
149            } else {
150                Conf::msg_error("Invalid date.");
151                $this->error = true;
152            }
153        }
154    }
155
156    function selectors() {
157        $sel = [];
158        $last = null;
159        foreach ($this->sel as $n => $d) {
160            $flags = $this->selflags[$n];
161            if ($flags & self::F_GROUP) {
162                if ($d !== null)
163                    $sel[$n] = ["optgroup", $d];
164                else if ($last !== null
165                         && ($this->selflags[$last] & self::F_GROUP))
166                    unset($sel[$last]);
167                else
168                    $sel[$n] = ["optgroup"];
169            } else if (!($flags & self::F_HIDE) || $n == $this->type) {
170                if (is_string($d))
171                    $d = ["label" => $d];
172                $k = null;
173                if ($flags & self::F_NOPAPERS)
174                    $k[] = "mail-want-no-papers";
175                if ($flags & self::F_SINCE)
176                    $k[] = "mail-want-since";
177                if (!empty($k))
178                    $d["class"] = join(" ", $k);
179                $sel[$n] = $d;
180            } else
181                continue;
182            $last = $n;
183        }
184        return Ht::select("recipients", $sel, $this->type, ["id" => "recipients"]);
185    }
186
187    function unparse() {
188        $t = $this->sel[$this->type];
189        if ($this->type == "newpcrev" && $this->newrev_since)
190            $t .= " since " . htmlspecialchars($this->conf->parseableTime($this->newrev_since, false));
191        return $t;
192    }
193
194    function need_papers() {
195        return $this->type !== "pc" && substr($this->type, 0, 3) !== "pc:"
196            && $this->type !== "all";
197    }
198
199    function combination_type($paper_sensitive) {
200        if (preg_match('/\A(?:pc|pc:.*|(?:|unc|new)pcrev|lead|shepherd)\z/', $this->type))
201            return 2;
202        else if ($paper_sensitive)
203            return 1;
204        else
205            return 0;
206    }
207
208    function query($paper_sensitive) {
209        $cols = array();
210        $where = array("email not regexp '^anonymous[0-9]*\$'");
211        $joins = array("ContactInfo");
212
213        // paper limit
214        if ($this->need_papers() && isset($this->papersel))
215            $where[] = "Paper.paperId in (" . join(",", $this->papersel) . ")";
216
217        // paper type limit
218        if ($this->type == "s")
219            $where[] = "Paper.timeSubmitted>0";
220        else if ($this->type == "unsub")
221            $where[] = "Paper.timeSubmitted<=0 and Paper.timeWithdrawn<=0";
222        else if ($this->type == "dec:any")
223            $where[] = "Paper.timeSubmitted>0 and Paper.outcome!=0";
224        else if ($this->type == "dec:none")
225            $where[] = "Paper.timeSubmitted>0 and Paper.outcome=0";
226        else if ($this->type == "dec:yes")
227            $where[] = "Paper.timeSubmitted>0 and Paper.outcome>0";
228        else if ($this->type == "dec:no")
229            $where[] = "Paper.timeSubmitted>0 and Paper.outcome<0";
230        else if (substr($this->type, 0, 4) == "dec:") {
231            $nw = count($where);
232            foreach ($this->conf->decision_map() as $dnum => $dname)
233                if (strcasecmp($dname, substr($this->type, 4)) == 0) {
234                    $where[] = "Paper.timeSubmitted>0 and Paper.outcome=$dnum";
235                    break;
236                }
237            if (count($where) == $nw)
238                return false;
239        }
240
241        // additional manager limit
242        if (!$this->contact->privChair
243            && !($this->selflags[$this->type] & self::F_ANYPC))
244            $where[] = "Paper.managerContactId=" . $this->contact->contactId;
245
246        // reviewer limit
247        if (!preg_match('_\A(new|unc|c|allc|)(pc|ext|myext|)rev\z_',
248                        $this->type, $revmatch))
249            $revmatch = false;
250
251        // build query
252        if ($this->type == "all") {
253            $needpaper = $needconflict = $needreview = false;
254        } else if ($this->type == "pc" || substr($this->type, 0, 3) == "pc:") {
255            $needpaper = $needconflict = $needreview = false;
256            $where[] = "(ContactInfo.roles&" . Contact::ROLE_PC . ")!=0";
257            if ($this->type != "pc")
258                $where[] = "ContactInfo.contactTags like " . Dbl::utf8ci("'% " . sqlq_for_like(substr($this->type, 3)) . "#%'");
259        } else if ($revmatch) {
260            $needpaper = $needreview = true;
261            $needconflict = false;
262            $joins[] = "join Paper";
263            $joins[] = "join PaperReview on (PaperReview.paperId=Paper.paperId and PaperReview.contactId=ContactInfo.contactId)";
264            $where[] = "Paper.paperId=PaperReview.paperId";
265        } else if ($this->type == "lead" || $this->type == "shepherd") {
266            $needpaper = $needconflict = $needreview = true;
267            $joins[] = "join Paper on (Paper.{$this->type}ContactId=ContactInfo.contactId)";
268            $joins[] = "left join PaperReview on (PaperReview.paperId=Paper.paperId and PaperReview.contactId=ContactInfo.contactId)";
269        } else {
270            $needpaper = $needconflict = true;
271            $needreview = false;
272            if ($this->conf->au_seerev == Conf::AUSEEREV_UNLESSINCOMPLETE) {
273                $cols[] = "(coalesce(allr.contactId,0)!=0) has_review";
274                $cols[] = "coalesce(allr.has_outstanding_review,0) has_outstanding_review";
275                $joins[] = "left join (select contactId, max(if(reviewNeedsSubmit!=0 and timeSubmitted>0,1,0)) has_outstanding_review from PaperReview join Paper on (Paper.paperId=PaperReview.paperId) group by PaperReview.contactId) as allr using (contactId)";
276            }
277            $joins[] = "join Paper";
278            $where[] = "PaperConflict.conflictType>=" . CONFLICT_AUTHOR;
279            if ($this->conf->au_seerev == Conf::AUSEEREV_TAGS) {
280                $joins[] = "left join (select paperId, group_concat(' ', tag, '#', tagIndex order by tag separator '') as paperTags from PaperTag group by paperId) as PaperTags on (PaperTags.paperId=Paper.paperId)";
281                $cols[] = "PaperTags.paperTags";
282            }
283        }
284
285        // reviewer match
286        if ($revmatch) {
287            // Submission status
288            if ($revmatch[1] == "c")
289                $where[] = "PaperReview.reviewSubmitted>0";
290            else if ($revmatch[1] == "unc" || $revmatch[1] == "new")
291                $where[] = "PaperReview.reviewSubmitted is null and PaperReview.reviewNeedsSubmit!=0 and Paper.timeSubmitted>0";
292            if ($revmatch[1] == "new")
293                $where[] = "PaperReview.timeRequested>PaperReview.timeRequestNotified";
294            if ($revmatch[1] == "allc") {
295                $joins[] = "left join (select contactId, max(if(reviewNeedsSubmit!=0 and timeSubmitted>0,1,0)) anyReviewNeedsSubmit from PaperReview join Paper on (Paper.paperId=PaperReview.paperId) group by contactId) AllReviews on (AllReviews.contactId=ContactInfo.contactId)";
296                $where[] = "AllReviews.anyReviewNeedsSubmit=0";
297            }
298            if ($this->newrev_since)
299                $where[] = "PaperReview.timeRequested>=$this->newrev_since";
300            // Withdrawn papers may not count
301            if ($revmatch[1] == "")
302                $where[] = "(Paper.timeSubmitted>0 or PaperReview.reviewSubmitted>0)";
303            // Review type
304            if ($revmatch[2] == "ext" || $revmatch[2] == "myext")
305                $where[] = "PaperReview.reviewType=" . REVIEW_EXTERNAL;
306            else if ($revmatch[2] == "pc")
307                $where[] = "PaperReview.reviewType>" . REVIEW_EXTERNAL;
308            if ($revmatch[2] == "myext")
309                $where[] = "PaperReview.requestedBy=" . $this->contact->contactId;
310        }
311
312        // query construction
313        $q = "select ContactInfo.contactId, firstName, lastName, email,
314            password, roles, contactTags, preferredEmail, "
315            . ($needconflict ? "PaperConflict.conflictType" : "0 as conflictType");
316        if ($needpaper)
317            $q .= ", Paper.paperId, Paper.title, Paper.abstract,
318                Paper.authorInformation, Paper.outcome, Paper.blind,
319                Paper.timeSubmitted, Paper.timeWithdrawn,
320                Paper.shepherdContactId, Paper.capVersion,
321                Paper.managerContactId";
322        else
323            $q .= ", -1 as paperId";
324        if ($needreview) {
325            if (!$revmatch || $this->type === "rev")
326                $q .= ", " . PaperInfo::my_review_permissions_sql("PaperReview.") . " myReviewPermissions";
327            else
328                $q .= ", (select " . PaperInfo::my_review_permissions_sql() . " from PaperReview where PaperReview.paperId=Paper.paperId and PaperReview.contactId=ContactInfo.contactId group by paperId) myReviewPermissions";
329        } else
330            $q .= ", '' myReviewPermissions";
331        if ($needconflict)
332            $joins[] = "left join PaperConflict on (PaperConflict.paperId=Paper.paperId and PaperConflict.contactId=ContactInfo.contactId)";
333        $q .= "\nfrom " . join("\n", $joins) . "\nwhere "
334            . join("\n    and ", $where) . "\ngroup by ContactInfo.contactId";
335        if ($needpaper)
336            $q .= ", Paper.paperId";
337        $q .= "\norder by ";
338        if (!$needpaper)
339            $q .= "email";
340        else if ($paper_sensitive)
341            $q .= "Paper.paperId, email";
342        else
343            $q .= "email, Paper.paperId";
344        return $q;
345    }
346}
347