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