__('GroupEntry – group registrations and staff activity', 'groupentry'), 'callback' => [self::class, 'export'], ]; return $exporters; } public static function erasers($erasers) { $erasers['groupentry'] = [ 'eraser_friendly_name' => __('GroupEntry – entire group registrations and staff identity', 'groupentry'), 'callback' => [self::class, 'erase'], ]; return $erasers; } private static function input($email, $page, $capability) { if (!current_user_can($capability)) { return new WP_Error('groupentry_privacy_access', __('Access denied.', 'groupentry')); } $number = filter_var($page, FILTER_VALIDATE_INT); if (!is_string($email) || strlen($email) > 254 || !is_email($email) || $number === false || $number === null || $number < 1 || $number > intdiv(PHP_INT_MAX, self::PAGE_SIZE)) { return new WP_Error('groupentry_privacy_input', __('Enter a valid email address and privacy request page.', 'groupentry')); } return [strtolower($email), $number]; } private static function rows($sql, array $args) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Private helper binds every identifier and value from fixed caller templates; transaction reads cannot use stale caches. $rows = $wpdb->get_results($wpdb->prepare($sql, $args)); if ($wpdb->last_error) { throw new RuntimeException('Privacy query failed.'); } return $rows; } private static function item($group, $label, $id, $data) { $pairs = []; foreach ($data as $name => $value) { // WordPress renders these values as limited HTML in the personal-data report. $pairs[] = ['name' => $name, 'value' => esc_html((string)$value)]; } return ['group_id' => $group, 'group_label' => $label, 'item_id' => $id, 'data' => $pairs]; } private static function email_rates($email) { global $wpdb; $table = GroupEntry_Core::table('rates'); // Rate expiry identifies its ten-minute window. Normally only two windows remain. $windows = self::rows('SELECT DISTINCT expires_at FROM %i', [$table]); $keys = []; foreach ($windows as $row) { $window = intdiv((int)$row->expires_at, 600) - 1; $keys[] = hash_hmac('sha256', 'email:' . $email . ':' . $window, wp_salt('auth')); } if (!$keys) { return []; } return self::rows('SELECT bucket,hits,expires_at FROM %i WHERE bucket IN (' . implode(',', array_fill(0, count($keys), '%s')) . ') ORDER BY expires_at', array_merge([$table],$keys)); } public static function export($email, $page = 1) { $input = self::input($email, $page, 'export_others_personal_data'); if (is_wp_error($input)) { return $input; } [$email, $page] = $input; try { return GroupEntry_Core::transaction(static function($settings) use ($email, $page) { global $wpdb; $regs = GroupEntry_Core::table('registrations'); $slots = GroupEntry_Core::table('slots'); $audit = GroupEntry_Core::table('audit'); $offset = ($page - 1) * self::PAGE_SIZE; $limit = self::PAGE_SIZE + 1; $data = []; $done = true; $rows = self::rows('SELECT r.*,s.starts_at,s.ends_at FROM %i r LEFT JOIN %i s ON s.id=r.slot_id WHERE r.email=%s ORDER BY r.id LIMIT %d OFFSET %d', [$regs, $slots, $email, $limit, $offset]); $done = count($rows) <= self::PAGE_SIZE; foreach (array_slice($rows, 0, self::PAGE_SIZE) as $r) { $data[] = self::item('groupentry-registrations', __('GroupEntry registrations', 'groupentry'), 'groupentry-registration-' . $r->id, [ __('Event', 'groupentry') => $settings['title'], __('Registration code:', 'groupentry') => $r->code, __('Contact first name', 'groupentry') => $r->contact_first, __('Contact last name', 'groupentry') => $r->contact_last, __('Email', 'groupentry') => $r->email, __('Reserved start', 'groupentry') => $r->starts_at, __('Reserved end', 'groupentry') => $r->ends_at, __('Event time zone', 'groupentry') => $settings['timezone'], __('Participant count', 'groupentry') => $r->participant_count, __('Registration status', 'groupentry') => $r->status, __('Created (UTC)', 'groupentry') => $r->created_at, __('Updated (UTC)', 'groupentry') => $r->updated_at, __('Check-in time (UTC)', 'groupentry') => $r->checked_at, __('Email status', 'groupentry') => $r->mail_status, ]); $people = self::rows('SELECT id,first_name,last_name,age FROM %i WHERE registration_id=%d ORDER BY id', [GroupEntry_Core::table('participants'), $r->id]); foreach ($people as $person) { $fields = [__('Registration code:', 'groupentry') => $r->code, __('First name', 'groupentry') => $person->first_name, __('Last name', 'groupentry') => $person->last_name]; if ($person->age !== null) { $fields[__('Age', 'groupentry')] = $person->age; } $data[] = self::item('groupentry-participants', __('Participants supplied in GroupEntry registrations', 'groupentry'), 'groupentry-participant-' . $person->id, $fields); } } $activity = self::rows('SELECT a.id,a.action,a.created_at,r.code FROM %i a JOIN %i r ON r.id=a.registration_id WHERE r.email=%s ORDER BY a.id LIMIT %d OFFSET %d', [$audit, $regs, $email, $limit, $offset]); $done = $done && count($activity) <= self::PAGE_SIZE; foreach (array_slice($activity, 0, self::PAGE_SIZE) as $a) { $data[] = self::item('groupentry-registration-activity', __('GroupEntry registration activity', 'groupentry'), 'groupentry-registration-activity-' . $a->id, [__('Registration code:', 'groupentry') => $a->code, __('Action', 'groupentry') => $a->action, __('Time (UTC)', 'groupentry') => $a->created_at]); } $user = get_user_by('email', $email); if ($user) { $activity = self::rows('SELECT id,action,created_at FROM %i WHERE user_id=%d ORDER BY id LIMIT %d OFFSET %d', [$audit, $user->ID, $limit, $offset]); $done = $done && count($activity) <= self::PAGE_SIZE; foreach (array_slice($activity, 0, self::PAGE_SIZE) as $a) { $data[] = self::item('groupentry-staff-activity', __('Your GroupEntry staff activity', 'groupentry'), 'groupentry-staff-activity-' . $a->id, [__('Action', 'groupentry') => $a->action, __('Time (UTC)', 'groupentry') => $a->created_at]); } $checkins = self::rows('SELECT id,checked_at FROM %i WHERE checked_by=%d ORDER BY id LIMIT %d OFFSET %d', [$regs, $user->ID, $limit, $offset]); $done = $done && count($checkins) <= self::PAGE_SIZE; foreach (array_slice($checkins, 0, self::PAGE_SIZE) as $r) { $data[] = self::item('groupentry-staff-checkins', __('Your GroupEntry check-ins', 'groupentry'), 'groupentry-staff-checkin-' . $r->id, [__('Check-in time (UTC)', 'groupentry') => $r->checked_at]); } } if ($page === 1) { foreach (self::email_rates($email) as $rate) { $data[] = self::item('groupentry-email-limits', __('GroupEntry email request limits', 'groupentry'), 'groupentry-email-limit-' . $rate->expires_at, [__('Request count', 'groupentry') => $rate->hits, __('Expires (UTC)', 'groupentry') => gmdate('Y-m-d H:i:s', (int)$rate->expires_at)]); } } return ['data' => $data, 'done' => $done]; }); } catch (Throwable $e) { return new WP_Error('groupentry_privacy_export', __('GroupEntry personal data could not be exported. Check database availability and retry.', 'groupentry')); } } public static function erase($email, $page = 1) { $input = self::input($email, $page, 'erase_others_personal_data'); if (is_wp_error($input)) { return $input; } [$email] = $input; try { return GroupEntry_Core::transaction(static function($settings) use ($email) { global $wpdb; $regs = GroupEntry_Core::table('registrations'); $audit = GroupEntry_Core::table('audit'); $removed = false; $messages = []; // Always take the first remaining batch: offsets would skip rows after deletion. $rows = self::rows('SELECT id FROM %i WHERE email=%s ORDER BY id LIMIT %d', [$regs, $email, self::PAGE_SIZE]); foreach ($rows as $r) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. GroupEntry_Core::assert_write($wpdb->delete(GroupEntry_Core::table('participants'), ['registration_id' => $r->id])); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. GroupEntry_Core::assert_write($wpdb->delete($audit, ['registration_id' => $r->id])); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. GroupEntry_Core::assert_write($wpdb->delete($regs, ['id' => $r->id])); $removed = true; } if ($rows) { $messages[] = __('GroupEntry removed entire registrations for this contact email, including participants and check-in records. Reserved places were released. Private management links no longer work.', 'groupentry'); } $user = get_user_by('email', $email); $more = (bool)self::rows('SELECT id FROM %i WHERE email=%s LIMIT 1', [$regs, $email]); if ($user) { $activity = self::rows('SELECT id FROM %i WHERE user_id=%d ORDER BY id LIMIT %d', [$audit, $user->ID, self::PAGE_SIZE]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. foreach ($activity as $a) { GroupEntry_Core::assert_write($wpdb->update($audit, ['user_id' => 0], ['id' => $a->id])); $removed = true; } $checkins = self::rows('SELECT id FROM %i WHERE checked_by=%d ORDER BY id LIMIT %d', [$regs, $user->ID, self::PAGE_SIZE]); foreach ($checkins as $r) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. GroupEntry_Core::assert_write($wpdb->query($wpdb->prepare('UPDATE %i SET checked_by=NULL,revision=revision+1 WHERE id=%d', $regs, $r->id))); $removed = true; } if ($activity || $checkins) { $messages[] = __('GroupEntry removed this WordPress user identity from staff activity and check-in records. Other groups and their attendance were preserved. Shared-code activity has no individual staff identity.', 'groupentry'); } $more = $more || (bool)self::rows('SELECT id FROM %i WHERE user_id=%d LIMIT 1', [$audit, $user->ID]) || (bool)self::rows('SELECT id FROM %i WHERE checked_by=%d LIMIT 1', [$regs, $user->ID]); } foreach (self::email_rates($email) as $rate) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. GroupEntry_Core::assert_write($wpdb->delete(GroupEntry_Core::table('rates'), ['bucket' => $rate->bucket])); $removed = true; } if ($removed) { $messages[] = __('Email copies, CSV downloads, WordPress privacy export files, hosting logs and backups are separate copies managed by the organizer or the relevant service.', 'groupentry'); } return ['items_removed' => $removed, 'items_retained' => false, 'messages' => $messages, 'done' => !$more]; }); } catch (Throwable $e) { return new WP_Error('groupentry_privacy_erase', __('GroupEntry personal data could not be erased. No changes from this batch were saved. Check database availability and retry.', 'groupentry')); } } public static function notice() { $screen = get_current_screen(); if (!$screen || !in_array($screen->id, ['export-personal-data', 'erase-personal-data'], true)) { return; } if (!current_user_can($screen->id === 'erase-personal-data' ? 'erase_others_personal_data' : 'export_others_personal_data')) { return; } $erase = $screen->id === 'erase-personal-data'; echo '

GroupEntry: ' . esc_html($erase ? __('Erasing a contact email removes its entire group registrations, all participants and check-in records, and releases reserved places. Review the group scope before processing the request. Staff requests remove the staff identity without cancelling other groups.', 'groupentry') : __('The export includes complete group registrations associated with the contact email, including participant names and any ages supplied. Verify that the requester is entitled to the group data. Participants without their own stored email need a request reviewed manually by the organizer.', 'groupentry')) . '

'; } public static function policy() { if (!function_exists('wp_add_privacy_policy_content')) { return; } $text = '

' . esc_html__('Review and adapt this suggested text to your event, hosting and mail services. Choose and explain why participant data is needed. Requests from individual participants need manual identity and group verification because only the contact email is stored.', 'groupentry') . '

'; $paragraphs = [ __('We use GroupEntry to manage event registrations and group check-in. We store the contact person name and email, participant names and optional ages, reserved times, registration and email statuses, and registration activity timestamps. Actions by signed-in staff can also store their WordPress user ID. Shared staff codes do not identify individual operators.', 'groupentry'), __('Registration emails containing group details are sent through this website mail service. Administrators manage registrations; authorized check-in staff can see participant names, optional ages, reserved times and attendance, but cannot see contact email addresses. Private management links allow access to the group details and must not be shared.', 'groupentry'), __('Registration data is stored in this website database and is removed after the organizer configured retention period following the event. Scheduled cleanup depends on site traffic or hosting cron. Emails, downloaded exports, hosting logs and backups can have separate retention policies.', 'groupentry'), __('Short-lived request limits use keyed hashes of email addresses or IP addresses rather than storing those addresses in the rate-limit table. Private links and shared-code sessions use random tokens; the database stores hashes of management tokens. Staff sessions can last up to 12 hours. The plugin does not add analytics or a payment processor.', 'groupentry'), __('Contact the organizer about access or erasure of registration data. WordPress privacy tools can export group registrations for a contact email and remove those entire registrations, releasing their reserved places. Staff identity can be removed from activity records without deleting other groups. Requests concerning individual participants or copies held by other services require organizer review.', 'groupentry'), ]; foreach ($paragraphs as $paragraph) { $text .= '

' . esc_html($paragraph) . '

'; } wp_add_privacy_policy_content('GroupEntry', $text); } }