_exp`), so the * daily sweep can ask the database for expired records only, oldest first, instead of reading every record: a few valid records can never * hide expired ones. Records are ignored once expired, removed by the daily core retention job (also when the module is off), when the * user leaves the site, when the site is deleted, on uninstall, and through the WordPress personal-data tools. */ final class ContentStudioRetention { public const PROPOSAL_TTL = 86400; public const BRIEF_TTL = 3600; /** Records removed per query, and queries per run: at most 5 x 200 per kind and day; the rest is picked up the next day (oldest first). */ public const SWEEP_BATCH = 200; public const SWEEP_BATCHES = 5; public const REQUEST_META = '_aiwend_cs_request'; /** Per-proposal serialization rows of the save transaction (wp_options, value = creation time); normally deleted right after the save, leftovers are swept after a day. */ public const LOCK_PREFIX = 'aiwend_cs_lock_'; private const KINDS = ['proposal', 'brief']; public static function key(string $what, int $blogId): string { return 'aiwend_cs_' . $what . '_' . $blogId; } public static function expiryKey(string $what, int $blogId): string { return self::key($what, $blogId) . '_exp'; } public static function ttl(string $what): int { return $what === 'brief' ? self::BRIEF_TTL : self::PROPOSAL_TTL; } public static function expired(mixed $record, string $what, int $now): bool { return !is_array($record) || !isset($record['at']) || (int) $record['at'] < $now - self::ttl($what); } /** Stores the record and its expiry row. $record['at'] is its creation time. */ public static function store(int $userId, string $what, int $blogId, array $record): void { update_user_meta($userId, self::key($what, $blogId), $record); update_user_meta($userId, self::expiryKey($what, $blogId), (int) $record['at'] + self::ttl($what)); } public static function forget(int $userId, string $what, int $blogId): void { delete_user_meta($userId, self::key($what, $blogId)); delete_user_meta($userId, self::expiryKey($what, $blogId)); } public static function register(): void { add_action('remove_user_from_blog', static function ($userId, $blogId): void { foreach (self::KINDS as $what) { self::forget((int) $userId, $what, (int) $blogId); } }, 10, 2); add_action('wp_uninitialize_site', static function ($site): void { self::purgeSite((int) ($site->blog_id ?? 0)); }); add_filter('wp_privacy_personal_data_exporters', static function (array $exporters): array { $exporters['aiwend-content-studio'] = ['exporter_friendly_name' => __('Aiwend Content Studio', 'aiwend'), 'callback' => [self::class, 'export']]; return $exporters; }); add_filter('wp_privacy_personal_data_erasers', static function (array $erasers): array { $erasers['aiwend-content-studio'] = ['eraser_friendly_name' => __('Aiwend Content Studio', 'aiwend'), 'callback' => [self::class, 'erase']]; return $erasers; }); } /** * Removes expired records of this site: expired only, oldest first, at most SWEEP_BATCH per query and SWEEP_BATCHES queries per run. * A record is deleted only if it still holds the value that was read (compare-and-delete), so a proposal replaced in the meantime is * never erased; repeated runs are harmless. Returns the number of records removed. */ public static function sweep(int $blogId, int $now, int $batch = self::SWEEP_BATCH, int $batches = self::SWEEP_BATCHES): int { global $wpdb; $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND CAST(option_value AS UNSIGNED) < %d LIMIT 200", $wpdb->esc_like(self::LOCK_PREFIX) . '%', $now - 86400)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bounded daily housekeeping of this plugin's own rows $removed = 0; foreach (self::KINDS as $what) { $key = self::key($what, $blogId); $expKey = self::expiryKey($what, $blogId); for ($i = 0; $i < $batches; $i++) { $users = get_users([ 'blog_id' => 0, 'fields' => 'ID', 'number' => $batch, 'orderby' => 'meta_value_num', 'order' => 'ASC', 'meta_key' => $expKey, 'meta_value' => $now, 'meta_compare' => '<=', 'meta_type' => 'NUMERIC', // phpcs:ignore WordPress.DB.SlowDBQuery -- bounded, daily, own key ]); foreach ($users as $id) { $id = (int) $id; $record = get_user_meta($id, $key, true); $stamp = get_user_meta($id, $expKey, true); if ($record === '' || self::expired($record, $what, $now)) { // Only the exact values that were read are removed: a newer record written meanwhile keeps both rows. if ($record !== '' && delete_metadata('user', $id, $key, $record)) { $removed++; } delete_metadata('user', $id, $expKey, $stamp); } else { // The expiry row was stale (the record was replaced): bring it in line so the next query does not return it again. update_user_meta($id, $expKey, (int) $record['at'] + self::ttl($what)); } } if (count($users) < $batch) { break; } } } return $removed; } /** Removes every record of one site (site deleted, uninstall). */ public static function purgeSite(int $blogId): void { if ($blogId <= 0) { return; } foreach (self::KINDS as $what) { delete_metadata('user', 0, self::key($what, $blogId), '', true); delete_metadata('user', 0, self::expiryKey($what, $blogId), '', true); } } /** Opt-in uninstall of one site: the records above and the idempotency marker on saved drafts. Metadata only: no post is deleted and no other plugin's meta is touched. */ public static function uninstall(int $blogId): void { self::purgeSite($blogId); delete_metadata('post', 0, self::REQUEST_META, '', true); } /** @return array{data: list>, done: bool} the caller's own records on this site only */ public static function export(string $email, int $page = 1): array { $user = get_user_by('email', $email); if (!$user) { return ['data' => [], 'done' => true]; } $blog = get_current_blog_id(); $now = time(); $items = []; foreach (self::KINDS as $what) { $r = get_user_meta((int) $user->ID, self::key($what, $blog), true); if (self::expired($r, $what, $now)) { continue; } $rows = [ ['name' => __('Content type', 'aiwend'), 'value' => (string) ($r['type'] ?? '')], ['name' => __('Created', 'aiwend'), 'value' => gmdate('Y-m-d H:i:s', (int) $r['at']) . ' UTC'], ]; if ($what === 'proposal') { array_push($rows, ['name' => __('Title', 'aiwend'), 'value' => (string) ($r['title'] ?? '')], ['name' => __('Excerpt', 'aiwend'), 'value' => (string) ($r['excerpt'] ?? '')], ['name' => __('Content', 'aiwend'), 'value' => (string) ($r['body'] ?? '')]); } else { $rows[] = ['name' => __('Brief', 'aiwend'), 'value' => (string) ($r['brief'] ?? '')]; } $items[] = [ 'group_id' => 'aiwend-content-studio', 'group_label' => __('Content Studio drafts in progress', 'aiwend'), 'item_id' => 'content-studio-' . $what . '-' . $blog, 'data' => $rows, ]; } return ['data' => $items, 'done' => true]; } /** @return array{items_removed: bool, items_retained: bool, messages: list, done: bool} */ public static function erase(string $email, int $page = 1): array { $user = get_user_by('email', $email); if (!$user) { return ['items_removed' => false, 'items_retained' => false, 'messages' => [], 'done' => true]; } $blog = get_current_blog_id(); $removed = false; foreach (self::KINDS as $what) { $removed = $removed || get_user_meta((int) $user->ID, self::key($what, $blog), true) !== ''; self::forget((int) $user->ID, $what, $blog); } return [ 'items_removed' => $removed, 'items_retained' => false, 'messages' => $removed ? [__('Content Studio drafts in progress were deleted. Drafts already saved are ordinary WordPress posts: review them under Posts and Pages.', 'aiwend')] : [], 'done' => true, ]; } }