prefix . 'groupentry_' . $name; } public static function defaults() { return [ 'title' => __('Your event', 'groupentry'), 'subtitle' => '', 'date' => wp_date('Y-m-d', time() + WEEK_IN_SECONDS), 'start' => '13:00', 'end' => '17:00', 'duration' => 30, 'registration_mode' => 'slots', 'timezone' => wp_timezone()->getName(), 'capacity' => 30, 'max_group_size' => 10, 'collect_age' => false, 'min_age' => 0, 'max_age' => 120, 'retention_days' => 30, 'delete_on_uninstall' => false, 'open' => false, 'page_id' => 0, 'confirmation_mode' => 'automatic', 'pending_email_text' => '', 'privacy' => '', 'contact' => '', 'email_note' => '', 'intro_heading' => __('Register your group', 'groupentry'), 'intro_text' => __('Choose a time slot and enter the names of the participants. The contact person receives a confirmation email with a private link to manage the registration.', 'groupentry'), 'email_text' => __('Please arrive at your reserved time. Contact the organizer if you need assistance.', 'groupentry'), ] + self::color_defaults(); } private static function default_text_sources() { return [ 'title' => 'Your event', 'intro_heading' => 'Register your group', 'intro_text' => 'Choose a time slot and enter the names of the participants. The contact person receives a confirmation email with a private link to manage the registration.', 'email_text' => 'Please arrive at your reserved time. Contact the organizer if you need assistance.', ]; } public static function registration_text_defaults() { $defaults = self::defaults(); return [ 'slots' => ['intro_text'=>$defaults['intro_text'], 'email_text'=>$defaults['email_text']], 'event' => [ 'intro_text'=>__('Enter the names of the participants to register for the whole event. The contact person receives a confirmation email with a private link to manage the registration.', 'groupentry'), 'email_text'=>__('Your registration covers the whole event. Contact the organizer if you need assistance.', 'groupentry'), ], ]; } private static function canonical_default_texts($settings) { static $known = null; $sources = self::default_text_sources(); if ($known === null) { $known = array_map(static fn($source) => [$source], $sources); $known['intro_text'][] = 'Enter the names of the participants to register for the whole event. The contact person receives a confirmation email with a private link to manage the registration.'; $known['email_text'][] = 'Your registration covers the whole event. Contact the organizer if you need assistance.'; $variants = $known; // Recognize exact saved defaults from installed WordPress language packs without switching locale. $catalogs = array_merge(glob(WP_LANG_DIR . '/plugins/groupentry-*.mo') ?: [], glob(WP_LANG_DIR . '/plugins/groupentry-*.l10n.php') ?: []); foreach ($catalogs as $file) { $catalog = WP_Translation_File::create($file); if ($catalog) { foreach ($variants as $key => $texts) { foreach ($texts as $source) { $translated = $catalog->translate($source); if (is_string($translated) && $translated !== '') { $known[$key][] = $translated; } } } } } } $localized = self::defaults(); $event_defaults = self::registration_text_defaults()['event']; foreach ($sources as $key => $source) { // Only an exact complete default is treated as a default. Edited or empty organizer text is preserved. if (isset($settings[$key]) && is_string($settings[$key]) && ($settings[$key] === $localized[$key] || $settings[$key] === ($event_defaults[$key] ?? null) || in_array($settings[$key], $known[$key], true))) { $settings[$key] = $source; } } return $settings; } public static function color_defaults() { return ['color_primary'=>'#573b8b','color_surface'=>'#ffffff','color_text'=>'#253133','color_checkin'=>'#26764d']; } public static function colors($settings) { $colors=self::color_defaults(); foreach($colors as $key=>$default) { if (isset($settings[$key]) && is_string($settings[$key]) && preg_match('/^#[0-9a-fA-F]{6}$/D',$settings[$key])) { $colors[$key]=strtolower($settings[$key]); } } return $colors; } public static function timezone($settings = null) { $settings = $settings ?? self::settings(); return new DateTimeZone($settings['timezone']); } public static function install() { global $wpdb; if (is_multisite()) { wp_die(esc_html__('GroupEntry supports single-site WordPress only.', 'groupentry')); } require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $charset = $wpdb->get_charset_collate(); $control = self::table('control'); $slots = self::table('slots'); $regs = self::table('registrations'); $participants = self::table('participants'); $rates = self::table('rates'); $audit = self::table('audit'); dbDelta("CREATE TABLE $control ( id bigint(20) unsigned NOT NULL, settings longtext NOT NULL, PRIMARY KEY (id) ) ENGINE=InnoDB $charset;"); dbDelta("CREATE TABLE $slots ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, starts_at datetime NOT NULL, ends_at datetime NOT NULL, capacity int unsigned NOT NULL, PRIMARY KEY (id), UNIQUE KEY starts_at (starts_at) ) ENGINE=InnoDB $charset;"); dbDelta("CREATE TABLE $regs ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, code varchar(20) NOT NULL, token_hash char(64) NOT NULL, create_hash char(64) NOT NULL, slot_id bigint(20) unsigned NOT NULL, contact_first varchar(100) NOT NULL, contact_last varchar(100) NOT NULL, email varchar(254) NOT NULL, participant_count smallint unsigned NOT NULL, status varchar(20) NOT NULL DEFAULT 'active', revision bigint(20) unsigned NOT NULL DEFAULT 1, created_at datetime NOT NULL, updated_at datetime NOT NULL, checked_at datetime NULL, checked_by bigint(20) unsigned NULL, mail_status varchar(20) NOT NULL DEFAULT 'pending', PRIMARY KEY (id), UNIQUE KEY token_hash (token_hash), UNIQUE KEY code (code), KEY slot_status (slot_id,status) ) ENGINE=InnoDB $charset;"); dbDelta("CREATE TABLE $participants ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, registration_id bigint(20) unsigned NOT NULL, first_name varchar(100) NOT NULL, last_name varchar(100) NOT NULL, age tinyint unsigned NULL, PRIMARY KEY (id), KEY registration_id (registration_id) ) ENGINE=InnoDB $charset;"); dbDelta("CREATE TABLE $rates ( bucket char(64) NOT NULL, hits int unsigned NOT NULL DEFAULT 1, expires_at bigint(20) unsigned NOT NULL, PRIMARY KEY (bucket), KEY expires_at (expires_at) ) ENGINE=InnoDB $charset;"); dbDelta("CREATE TABLE $audit ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, registration_id bigint(20) unsigned NOT NULL, action varchar(40) NOT NULL, user_id bigint(20) unsigned NOT NULL, created_at datetime NOT NULL, PRIMARY KEY (id), KEY registration_id (registration_id) ) ENGINE=InnoDB $charset;"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. $exists = $wpdb->get_var($wpdb->prepare('SELECT id FROM %i WHERE id=1', $control)); if (!$exists) { $s = self::canonical_default_texts(self::defaults()); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. self::assert_write($wpdb->insert($control, ['id' => 1, 'settings' => wp_json_encode($s)])); self::build_slots($s); } foreach (['control','slots','registrations','participants','rates','audit'] as $name) { $table = self::table($name); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. $row = $wpdb->get_row($wpdb->prepare('SHOW TABLE STATUS WHERE Name=%s', $table)); if (!$row || strtolower($row->Engine) !== 'innodb') { wp_die(esc_html__('GroupEntry requires InnoDB tables. Ask your hosting provider to enable them.', 'groupentry')); } } add_role('groupentry_checkin_staff', __('GroupEntry – Check-in staff', 'groupentry'), ['read' => true, 'groupentry_checkin' => true]); $role = get_role('administrator'); if ($role) { $role->add_cap('groupentry_checkin'); } update_option('groupentry_schema_version', GROUPENTRY_VERSION, false); } public static function settings($lock = false) { global $wpdb; $table = self::table('control'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. $json = $lock ? $wpdb->get_var($wpdb->prepare('SELECT settings FROM %i WHERE id=1 FOR UPDATE', $table)) : $wpdb->get_var($wpdb->prepare('SELECT settings FROM %i WHERE id=1', $table)); if (!$json) { self::failure(__('Plugin settings are unavailable.', 'groupentry')); } $defaults = self::defaults(); $settings = self::canonical_default_texts(array_merge($defaults, (array)json_decode($json, true))); foreach (self::default_text_sources() as $key => $source) { if ($settings[$key] === $source) { $settings[$key] = $settings['registration_mode'] === 'event' ? (self::registration_text_defaults()['event'][$key] ?? $defaults[$key]) : $defaults[$key]; } } return $settings; } public static function now() { return new DateTimeImmutable('now', self::timezone()); } public static function timestamp() { return gmdate('Y-m-d H:i:s'); } public static function event_over($s) { return self::now() >= new DateTimeImmutable($s['date'] . ' ' . $s['end'], self::timezone($s)); } public static function slot_started($slot) { return self::now()->format('Y-m-d H:i:s') >= $slot->starts_at; } public static function invalid(string $message): never { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- REST encodes errors as JSON; the UI renders textContent, not HTML. throw new InvalidArgumentException($message); } public static function failure(string $message): never { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal control flow; REST returns a generic JSON error, and HTML output is escaped at its boundary. throw new RuntimeException($message); } public static function assert_write($result) { if ($result === false) { self::failure(__('Database write failed. Please try again.', 'groupentry')); } return $result; } public static function transaction(callable $fn) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. self::assert_write($wpdb->query('START TRANSACTION')); try { $s = self::settings(true); // One event mutex serializes capacity checks and every mutation. $result = $fn($s); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. self::assert_write($wpdb->query('COMMIT')); return $result; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. } catch (Throwable $e) { $wpdb->query('ROLLBACK'); throw $e; } } public static function build_slots($s) { global $wpdb; $start = new DateTimeImmutable($s['date'] . ' ' . $s['start'], self::timezone($s)); $end = new DateTimeImmutable($s['date'] . ' ' . $s['end'], self::timezone($s)); for ($dt = $start; $dt < $end; $dt = $next) { $next = ($s['registration_mode'] ?? 'slots') === 'event' ? $end : $dt->modify('+' . $s['duration'] . ' minutes'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. self::assert_write($wpdb->insert(self::table('slots'), ['starts_at'=>$dt->format('Y-m-d H:i:s'), 'ends_at'=>$next->format('Y-m-d H:i:s'), 'capacity'=>$s['capacity']])); } } public static function slots() { global $wpdb; $t=self::table('slots'); $r=self::table('registrations'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. return $wpdb->get_results($wpdb->prepare("SELECT s.*, COALESCE(SUM(CASE WHEN r.status IN ('pending','active','checked_in') THEN r.participant_count ELSE 0 END),0) AS used, COALESCE(SUM(CASE WHEN r.status='checked_in' THEN r.participant_count ELSE 0 END),0) AS entered FROM %i s LEFT JOIN %i r ON r.slot_id=s.id GROUP BY s.id ORDER BY s.starts_at", $t, $r)); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. public static function slot($id) { global $wpdb; return $wpdb->get_row($wpdb->prepare('SELECT * FROM %i WHERE id=%d', self::table('slots'), $id)); } public static function occupied($id, $except=0) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. return (int)$wpdb->get_var($wpdb->prepare("SELECT COALESCE(SUM(participant_count),0) FROM %i WHERE slot_id=%d AND id<>%d AND status IN ('pending','active','checked_in')", self::table('registrations'), $id, $except)); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. public static function registration($id) { global $wpdb; return $wpdb->get_row($wpdb->prepare('SELECT * FROM %i WHERE id=%d', self::table('registrations'), $id)); } public static function by_token($token) { if (!is_string($token) || !preg_match('/^[a-f0-9]{64}$/D', $token)) { return null; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. return $wpdb->get_row($wpdb->prepare('SELECT * FROM %i WHERE token_hash=%s', self::table('registrations'), hash('sha256', $token))); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. public static function participants($id) { global $wpdb; return $wpdb->get_results($wpdb->prepare('SELECT first_name,last_name,age FROM %i WHERE registration_id=%d ORDER BY id', self::table('participants'), $id)); } public static function present($r) { $slot=self::slot($r->slot_id); return ['id'=>(int)$r->id,'version'=>(int)$r->revision,'code'=>$r->code,'slot_id'=>(int)$r->slot_id,'contact_first'=>$r->contact_first,'contact_last'=>$r->contact_last,'email'=>$r->email, 'participants'=>self::participants($r->id),'status'=>$r->status,'checked_at'=>$r->checked_at ? wp_date('d. m. Y H:i', strtotime($r->checked_at.' UTC'), self::timezone()) : null, 'mail_status'=>$r->mail_status, 'slot_label'=>$slot ? substr($slot->starts_at,11,5).' – '.substr($slot->ends_at,11,5) : '', 'can_edit'=>in_array($r->status,['pending','active'],true) && $slot && !self::slot_started($slot)]; } public static function name($v) { if (!is_string($v)) { self::invalid(__('Enter a first and last name.', 'groupentry')); } $v = trim(sanitize_text_field($v)); if ($v === '' || mb_strlen($v) > 100) { self::invalid(__('First and last names must each contain 1–100 characters.', 'groupentry')); } return $v; } public static function validate($data, $settings = null) { $s = $settings ?? self::settings(); $participants = $data['participants'] ?? null; if (!is_array($participants) || count($participants) < 1 || count($participants) > $s['max_group_size']) { /* translators: %d: maximum number of participants in one group. */ self::invalid(sprintf(__('A registration must contain between 1 and %d participants.', 'groupentry'), $s['max_group_size'])); } $clean = []; foreach ($participants as $person) { if (!is_array($person)) { self::invalid(__('Invalid participant details.', 'groupentry')); } $age = null; if ($s['collect_age']) { $age = filter_var($person['age'] ?? null, FILTER_VALIDATE_INT); if ($age === false || $age === null || $age < $s['min_age'] || $age > $s['max_age']) { /* translators: 1: minimum participant age, 2: maximum participant age. */ self::invalid(sprintf(__('Age must be between %1$d and %2$d.', 'groupentry'), $s['min_age'], $s['max_age'])); } } $clean[] = ['first_name'=>self::name($person['first_name'] ?? ''), 'last_name'=>self::name($person['last_name'] ?? ''), 'age'=>$age]; } $email = $data['email'] ?? ''; if (!is_string($email) || strlen($email) > 254 || !is_email($email)) { self::invalid(__('Enter a valid email address.', 'groupentry')); } $slot_id = filter_var($data['slot_id'] ?? 0, FILTER_VALIDATE_INT); if ($s['registration_mode'] === 'event') { $slots = self::slots(); if (count($slots) !== 1) { self::failure(__('Event registration is unavailable. Contact the organizer.', 'groupentry')); } if (isset($data['slot_id']) && $slot_id !== (int)$slots[0]->id) { self::invalid(__('Registration options have changed. Refresh the page and try again.', 'groupentry')); } $slot_id = (int)$slots[0]->id; } if (!$slot_id || $slot_id < 1) { self::invalid(__('Choose a time slot.', 'groupentry')); } return ['contact_first'=>self::name($data['contact_first'] ?? ''), 'contact_last'=>self::name($data['contact_last'] ?? ''), 'email'=>sanitize_email($email), 'slot_id'=>$slot_id, 'participants'=>$clean]; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. public static function audit($id,$action) { global $wpdb; self::assert_write($wpdb->insert(self::table('audit'), ['registration_id'=>$id,'action'=>$action,'user_id'=>get_current_user_id(),'created_at'=>self::timestamp()])); } public static function save($data,$id=0,$staff=false,$token='') { if (!$id && !$token) { $token=bin2hex(random_bytes(32)); } if ($token && !preg_match('/^[a-f0-9]{64}$/D',$token)) { self::invalid(__('Invalid request identifier.', 'groupentry')); } $result=self::transaction(static function($s) use($id,$staff,$token,$data) { global $wpdb; $d=self::validate($data,$s); $create_hash=hash('sha256',wp_json_encode($d)); if (!$id) { $existing=self::by_token($token); if ($existing) { if (!hash_equals($existing->create_hash,$create_hash)) { self::invalid(__('This registration already exists. Use its private management link.', 'groupentry')); } return ['id'=>(int)$existing->id,'duplicate'=>true]; } } $old=$id?self::registration($id):null; if ($id && !$old) { self::invalid(__('Registration not found.', 'groupentry')); } if ($old && !$staff && !hash_equals($old->token_hash, hash('sha256', $token))) { self::invalid(__('The management link is no longer valid.', 'groupentry')); } if ($old && !in_array($old->status,['pending','active'],true)) { self::invalid(__('Rejected, cancelled or checked-in registrations cannot be edited.', 'groupentry')); } if ($old && (!isset($data['version']) || (int)$data['version']!==(int)$old->revision)) { self::invalid(__('The registration has changed. Refresh the page before editing.', 'groupentry')); } $slot=self::slot($d['slot_id']); if (!$slot) { self::invalid(__('The selected time slot does not exist.', 'groupentry')); } if (!$staff) { if (!$id && (!$s['open'] || self::event_over($s))) { self::invalid(__('Registration is closed.', 'groupentry')); } if (self::slot_started($slot) || ($old && self::slot_started(self::slot($old->slot_id)))) { self::invalid($s['registration_mode'] === 'event' ? __('Registration and changes are unavailable after the event starts. Contact the organizer.', 'groupentry') : __('Changes are unavailable after the reserved slot starts. Contact the organizer.', 'groupentry')); } } if (self::occupied($slot->id,$id)+count($d['participants'])>(int)$slot->capacity) { self::invalid($s['registration_mode'] === 'event' ? __('There are not enough places available for this event.', 'groupentry') : __('There are not enough places in this time slot. Choose another time.', 'groupentry')); } $row=['slot_id'=>$d['slot_id'],'contact_first'=>$d['contact_first'],'contact_last'=>$d['contact_last'],'email'=>$d['email'],'participant_count'=>count($d['participants']),'updated_at'=>self::timestamp(),'mail_status'=>'pending']; if ($id) { $row['revision']=(int)$old->revision+1; // A participant's changes in manual mode must be reviewed again. if (!$staff && $s['confirmation_mode']==='manual') { $row['status']='pending'; } if ($staff && $token) { $row['token_hash']=hash('sha256',$token); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. self::assert_write($wpdb->update(self::table('registrations'),$row,['id'=>$id])); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. self::assert_write($wpdb->delete(self::table('participants'),['registration_id'=>$id])); } else { $row+=['code'=>'GE-'.strtoupper(bin2hex(random_bytes(4))),'token_hash'=>hash('sha256',$token),'create_hash'=>$create_hash,'status'=>$s['confirmation_mode']==='manual'?'pending':'active','created_at'=>self::timestamp()]; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. self::assert_write($wpdb->insert(self::table('registrations'),$row)); $id=(int)$wpdb->insert_id; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. foreach ($d['participants'] as $c) { $c['registration_id']=$id; self::assert_write($wpdb->insert(self::table('participants'),$c)); } self::audit($id,$old?'updated':'created'); return ['id'=>$id,'duplicate'=>false]; }); if (!$result['duplicate']) { self::mail($result['id'],$token,$id?'updated':'created'); } return self::present(self::registration($result['id'])); } public static function cancel($id,$staff=false,$token='') { self::transaction(static function($s) use($id,$staff,$token) { global $wpdb; $r=self::registration($id); if (!$r) { self::invalid(__('Registration not found.', 'groupentry')); } if (!$staff && !hash_equals($r->token_hash, hash('sha256', $token))) { self::invalid(__('The management link is no longer valid.', 'groupentry')); } if ($r->status==='cancelled') { return; } if ($r->status==='rejected') { self::invalid(__('This registration has been rejected.', 'groupentry')); } if ($r->status==='checked_in') { self::invalid(__('This registration is already checked in.', 'groupentry')); } if (!$staff && self::slot_started(self::slot($r->slot_id))) { self::invalid($s['registration_mode'] === 'event' ? __('The event has started. Contact the organizer.', 'groupentry') : __('The time slot has started. Contact the organizer.', 'groupentry')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. self::assert_write($wpdb->update(self::table('registrations'),['status'=>'cancelled','revision'=>(int)$r->revision+1,'mail_status'=>'pending','updated_at'=>self::timestamp()],['id'=>$id])); self::audit($id,'cancelled'); }); self::mail($id,'','cancelled'); return self::present(self::registration($id)); } public static function checkin($id,$version=null) { self::transaction(static function($s) use($id,$version) { global $wpdb; $r=self::registration($id); if (!$r || $r->status==='cancelled') { self::invalid(__('The registration does not exist or has been cancelled.', 'groupentry')); } if ($r->status==='checked_in') { return; } if ($r->status!=='active') { self::invalid(__('Only confirmed registrations can be checked in.', 'groupentry')); } if ($version!==null && (int)$version!==(int)$r->revision) { self::invalid(__('The registration has changed. Refresh the list and verify the participants again.', 'groupentry')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. self::assert_write($wpdb->update(self::table('registrations'),['status'=>'checked_in','revision'=>(int)$r->revision+1,'checked_at'=>self::timestamp(),'checked_by'=>get_current_user_id(),'updated_at'=>self::timestamp()],['id'=>$id])); self::audit($id,'checked_in'); }); return self::present(self::registration($id)); } public static function undo($id) { self::transaction(static function($s) use($id) { global $wpdb; $r=self::registration($id); if (!$r || $r->status!=='checked_in') { self::invalid(__('This registration has not been checked in.', 'groupentry')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. self::assert_write($wpdb->update(self::table('registrations'),['status'=>'active','revision'=>(int)$r->revision+1,'checked_at'=>null,'checked_by'=>null,'updated_at'=>self::timestamp()],['id'=>$id])); self::audit($id,'checkin_undone'); }); return self::present(self::registration($id)); } public static function decide($id,$version,$approve) { $token=$approve?bin2hex(random_bytes(32)):''; $revision=self::transaction(static function($s) use($id,$version,$approve,$token) { global $wpdb; $r=self::registration($id); if (!$r) { self::invalid(__('Registration not found.', 'groupentry')); } $v=filter_var($version,FILTER_VALIDATE_INT); if ($v===false || $v===null || $v!==(int)$r->revision) { self::invalid(__('The registration has changed. Refresh the list before confirming or rejecting it.', 'groupentry')); } if ($r->status!=='pending') { self::invalid(__('Only registrations awaiting confirmation can be confirmed or rejected.', 'groupentry')); } $row=['status'=>$approve?'active':'rejected','revision'=>(int)$r->revision+1,'mail_status'=>'pending','updated_at'=>self::timestamp()]; if ($approve) { $row['token_hash']=hash('sha256',$token); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. self::assert_write($wpdb->update(self::table('registrations'),$row,['id'=>$id])); self::audit($id,$approve?'approved':'rejected'); return $row['revision']; }); self::mail($id,$token,$approve?'approved':'rejected',$revision); return self::present(self::registration($id)); } public static function mail($id,$token,$action,$expected_revision=null) { global $wpdb; $r=self::registration($id); if (!$r) { return false; } if ($expected_revision!==null && (int)$r->revision!==$expected_revision) { return false; } if ($token && !hash_equals($r->token_hash,hash('sha256',$token))) { return false; } // The booking is already committed. Mail failures must not turn it into an API error. try { // Registration emails follow the site language even when an administrator uses another UI language. $switched=switch_to_locale(get_locale()); try { $ok=(bool)self::deliver_mail($r,$token,$action); } finally { if ($switched) { restore_previous_locale(); } } } catch (Throwable $e) { $ok=false; // Do not log message bodies, recipient addresses or private management links. // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Operational failure log deliberately excludes personal data and private tokens. error_log('GroupEntry: email delivery failed ('.get_class($e).'). Check mail configuration and retry.'); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. $wpdb->query($wpdb->prepare('UPDATE %i SET mail_status=%s WHERE id=%d AND revision=%d',self::table('registrations'),$ok?'sent':'failed',$id,$r->revision)); return $ok; } private static function deliver_mail($r,$token,$action) { $id=(int)$r->id; $s=self::settings(); $slot=self::slot($r->slot_id); if (!$slot) { return false; } $date=wp_date(get_option('date_format'),(new DateTimeImmutable($s['date'].' 12:00',self::timezone($s)))->getTimestamp(),self::timezone($s)); $time=substr($slot->starts_at,11,5).' – '.substr($slot->ends_at,11,5).' ('.$s['timezone'].')'; $subject=($r->status==='cancelled'?__('Registration cancelled','groupentry'):($r->status==='rejected'?__('Registration rejected','groupentry'):($r->status==='pending'?__('Registration awaiting confirmation','groupentry'):($action==='updated'?__('Registration updated','groupentry'):__('Registration confirmation','groupentry'))))).' – '.$s['title']; $body='
'.esc_html__('Registration code:','groupentry').' '.esc_html($r->code).'
'; $body.=''.esc_html__('Date:','groupentry').' '.esc_html($date).'
'.esc_html__('Time:','groupentry').' '.esc_html($time).'
'.esc_html__('Contact person:','groupentry').' '.esc_html($r->contact_first.' '.$r->contact_last).'
'.esc_html__('Participants:','groupentry').'
';
$lines=[];
foreach(self::participants($id) as $person) {
$label=$person->first_name.' '.$person->last_name;
/* translators: %d: participant age in years. */
if ($person->age!==null) { $label.=' – '.sprintf(__('age %d','groupentry'),$person->age); }
$lines[]=''.esc_html($label).'';
}
$body.=implode('
',$lines).'
'.esc_html__('Your registration has been cancelled and the places have been released.','groupentry').'
'; } elseif ($r->status==='rejected') { $body.=''.esc_html__('Your registration has been rejected and the places have been released. Contact the organizer if you have questions.','groupentry').'
'; } else { if ($r->status==='pending') { $body.=''.esc_html__('Your registration is awaiting administrator confirmation. Your places are reserved, but entry is available only after confirmation.','groupentry').'
'; $body.=self::mail_paragraphs($s['pending_email_text']); } else { if ($action==='approved') { $body.=''.esc_html__('Your registration has been confirmed.','groupentry').'
'; } $body.=self::mail_paragraphs($s['email_text']); } if ($token && $s['page_id']) { $url=get_permalink($s['page_id']).'#ge-manage='.$token; $body.=''.esc_html__('Edit or cancel your registration','groupentry').'
'.esc_html($s['registration_mode'] === 'event' ? __('Keep this private link to yourself. Changes are available until the event starts.', 'groupentry') : __('Keep this private link to yourself. Changes are available until your reserved time slot starts.','groupentry')).'
'.esc_html__('Organizer contact:','groupentry').' '.$contact.'
'; } $body.=self::mail_paragraphs($s['email_note']).''.nl2br(esc_html($paragraph),false).'
'; } } return $html; } public static function resend($id) { $token=bin2hex(random_bytes(32)); self::transaction(static function($s) use($id,$token) { global $wpdb; $r=self::registration($id); if(!$r) { self::invalid(__('Registration not found.', 'groupentry')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. self::assert_write($wpdb->update(self::table('registrations'),['token_hash'=>hash('sha256',$token),'revision'=>(int)$r->revision+1,'mail_status'=>'pending','updated_at'=>self::timestamp()],['id'=>$id])); self::audit($id,'email_resent'); }); $r=self::registration($id); self::mail($id,$token,$r->status==='cancelled'?'cancelled':'updated'); return self::present(self::registration($id)); } public static function update_settings($input) { if (!is_array($input)) { self::invalid(__('Invalid settings.', 'groupentry')); } return self::transaction(static function($old) use($input) { global $wpdb; $s=$old; foreach(['title','subtitle','contact','email_note','privacy','intro_heading','intro_text','email_text'] as $key) { if (!isset($input[$key]) || !is_string($input[$key])) { self::invalid(__('Complete the settings.', 'groupentry')); } $s[$key]=in_array($key,['title','subtitle','contact','intro_heading'],true) ? sanitize_text_field($input[$key]) : sanitize_textarea_field($input[$key]); if (mb_strlen($s[$key])>10000) { self::invalid(__('Setting text is too long.', 'groupentry')); } } if (array_key_exists('confirmation_mode',$input)) { if (!in_array($input['confirmation_mode'],['automatic','manual'],true)) { self::invalid(__('Choose automatic confirmation or administrator approval.', 'groupentry')); } $s['confirmation_mode']=$input['confirmation_mode']; } if (array_key_exists('registration_mode',$input)) { if (!in_array($input['registration_mode'],['slots','event'],true)) { self::invalid(__('Choose registration for the whole event or for time slots.', 'groupentry')); } $s['registration_mode']=$input['registration_mode']; } if (array_key_exists('pending_email_text',$input)) { if (!is_string($input['pending_email_text']) || mb_strlen($input['pending_email_text'])>10000) { self::invalid(__('Invalid awaiting-confirmation email text.', 'groupentry')); } $s['pending_email_text']=sanitize_textarea_field($input['pending_email_text']); } if (array_key_exists('delete_on_uninstall',$input)) { if (!is_bool($input['delete_on_uninstall'])) { self::invalid(__('Choose whether to remove data when uninstalling GroupEntry.', 'groupentry')); } $s['delete_on_uninstall']=$input['delete_on_uninstall']; } if (!$s['title']) { self::invalid(__('Enter the event title.', 'groupentry')); } foreach(self::color_defaults() as $key=>$default) { if (!array_key_exists($key,$input)) { continue; } if (!is_string($input[$key]) || !preg_match('/^#[0-9a-fA-F]{6}$/D',$input[$key])) { self::invalid(__('Choose a valid six-digit hexadecimal color, for example #573b8b.', 'groupentry')); } $s[$key]=strtolower($input[$key]); } $date=$input['date']??''; $dt=is_string($date)?DateTimeImmutable::createFromFormat('!Y-m-d',$date):false; if (!$dt || $dt->format('Y-m-d')!==$date) { self::invalid(__('Invalid date.', 'groupentry')); } foreach(['start','end'] as $key) { if (!is_string($input[$key]??null) || !preg_match('/^(?:[01][0-9]|2[0-3]):[0-5][0-9]$/D',$input[$key])) { self::invalid(__('Invalid time.', 'groupentry')); } $s[$key]=$input[$key]; } if (!is_string($input['timezone']??null) || strlen($input['timezone'])>100) { self::invalid(__('Invalid time zone.', 'groupentry')); } try { $zone=new DateTimeZone($input['timezone']); } catch (Exception $e) { self::invalid(__('Invalid time zone.', 'groupentry')); } $s['timezone']=$zone->getName(); $s['date']=$date; foreach(['duration'=>[5,240],'capacity'=>[1,10000],'max_group_size'=>[1,100],'min_age'=>[0,120],'max_age'=>[0,120],'retention_days'=>[1,3650]] as $key=>$range) { if ($key === 'duration' && $s['registration_mode'] === 'event') { continue; } $n=filter_var($input[$key]??null,FILTER_VALIDATE_INT); /* translators: %s: internal setting name that has an invalid value. */ if ($n===false || $n===null || $n<$range[0] || $n>$range[1]) { self::invalid(sprintf(__('Invalid value for %s.', 'groupentry'),$key)); } $s[$key]=$n; } if ($s['min_age']>$s['max_age']) { self::invalid(__('Minimum age cannot exceed maximum age.', 'groupentry')); } $s['collect_age']=($input['collect_age']??false)===true; $minutes=((int)substr($s['end'],0,2)*60+(int)substr($s['end'],3,2))-((int)substr($s['start'],0,2)*60+(int)substr($s['start'],3,2)); if ($minutes<=0) { self::invalid(__('The event must end after it starts, on the same date.', 'groupentry')); } if ($s['registration_mode'] === 'slots') { if ($minutes%$s['duration']!==0) { self::invalid(__('The event hours cannot be divided into complete time slots. Adjust the end time or the slot length.', 'groupentry')); } if ($minutes/$s['duration']>100) { self::invalid(__('An event can have at most 100 time slots. Increase the slot length or shorten the event.', 'groupentry')); } } // Reject clocks that PHP would normalize or duplicate during a DST transition. $start=new DateTimeImmutable($date.' '.$s['start'],$zone); $end=new DateTimeImmutable($date.' '.$s['end'],$zone); if ($start->format('Y-m-d H:i')!==$date.' '.$s['start'] || $end->format('Y-m-d H:i')!==$date.' '.$s['end'] || ($end->getTimestamp()-$start->getTimestamp())!==$minutes*60) { self::invalid(__('The event crosses a daylight saving time change. Choose different hours.', 'groupentry')); } $s['page_id']=absint($input['page_id']??0); $s['open']=($input['open']??false)===true; $page=$s['page_id']?get_post($s['page_id']):null; if ($s['open'] && (!$page || $page->post_status!=='publish' || $page->post_type!=='page' || !has_shortcode($page->post_content,'groupentry_registration') || !$s['privacy'] || !$s['contact'])) { self::invalid(__('Before opening registration, select a published registration page, enter organizer contact details and a privacy notice.', 'groupentry')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. $has_regs=(int)$wpdb->get_var($wpdb->prepare('SELECT COUNT(*) FROM %i',self::table('registrations')))>0; if ($has_regs && $old['registration_mode'] !== $s['registration_mode']) { self::invalid(__('Registration mode cannot change while registrations exist. Remove old registrations before changing it.', 'groupentry')); } $geometry=false; foreach(['date','start','end','duration','timezone','registration_mode'] as $key) { if ($old[$key]!=$s[$key]) { $geometry=true; } } foreach(['collect_age','min_age','max_age','max_group_size'] as $key) { if ($has_regs && $old[$key]!=$s[$key]) { self::invalid(__('Participant rules cannot change while registrations exist. Start the next event after removing old registrations.', 'groupentry')); } } if ($geometry) { if ($has_regs) { self::invalid(__('Event date, times and time zone cannot change while registrations exist.', 'groupentry')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. self::assert_write($wpdb->query($wpdb->prepare('DELETE FROM %i',self::table('slots')))); self::build_slots($s); } else { foreach(self::slots() as $slot) { if ((int)$slot->used>$s['capacity']) { self::invalid(__('Capacity cannot be below current bookings.', 'groupentry')); } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. self::assert_write($wpdb->query($wpdb->prepare('UPDATE %i SET capacity=%d',self::table('slots'),$s['capacity']))); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. self::assert_write($wpdb->update(self::table('control'),['settings'=>wp_json_encode(self::canonical_default_texts($s))],['id'=>1])); return $s; }); } public static function cleanup() { self::transaction(static function($s) { global $wpdb; $due=(new DateTimeImmutable($s['date'].' '.$s['end'],self::timezone($s)))->modify('+'.$s['retention_days'].' days'); if (self::now()<$due) { return; } $s['open']=false; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. self::assert_write($wpdb->update(self::table('control'),['settings'=>wp_json_encode($s)],['id'=>1])); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. foreach(['participants','registrations','audit','rates'] as $name) { self::assert_write($wpdb->query($wpdb->prepare('DELETE FROM %i',self::table($name)))); } }); } public static function purge($confirmation) { if($confirmation!=='DELETE') { self::invalid(__('Type DELETE to remove the data.', 'groupentry')); } self::transaction(static function($s) { global $wpdb; if($s['open']) { self::invalid(__('Close registration before removing data.', 'groupentry')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. foreach(['participants','registrations','audit','rates'] as $name) { self::assert_write($wpdb->query($wpdb->prepare('DELETE FROM %i',self::table($name)))); } }); return ['message'=>__('Registrations and check-in records have been removed.', 'groupentry')]; } }