full logs (PII), purged per answerrails_privacy_retention_days * anonymized -> Q/A only (no PII), kept indefinitely * - the idea registry (categories, statuses) and its writer, log_idea_item(). * - the retention purge for BOTH chatlog schemes: 'live' (the published, * hash-keyed posts AnswerRails_Response_Service writes) and 'legacy' * (private posts from pre-#395 releases — purged here, no longer created). * * #395: the legacy event writer (log_event() and its session-post helpers) * is retired. Conversation logs and the escalation record are created by * AnswerRails_Response_Service; this class inserts no answerrails_chatlog * post and writes no chatlog post_content or meta — it only deletes, in * the purge. */ class AnswerRails_Logger { // ---- Post type & meta keys ---- public const POST_TYPE = 'answerrails_chatlog'; public const META_IDENT = '_answerrails_legacy_is_identified'; // 1 or 0 (frozen per session) /** * Bootstrap from main plugin */ public static function bootstrap(): void { add_action('init', [__CLASS__, 'schedule_purge']); // daily retention add_action('answerrails_purge_old_logs', [__CLASS__, 'purge_old_logs']); // cron callback } /** * Logging mode, derived from the canonical privacy options. * Returns: 'identified' | 'anonymized' | 'off' * * ⚠ THE single authority for the effective logging mode (#109) — the * option is read in one place. Every PII decision (conversation * writer, session labels, escalation emails, consent receipts, admin * mode displays) MUST consult this method, never the raw * answerrails_privacy_logging_mode option: a second reader answers * differently, and PII lands in the log. Raw-option reads for PII * decisions are a regression; a test pins the known call sites. */ public static function logging_mode(): string { if (!(bool) get_option('answerrails_privacy_log_conversations', false)) { return 'off'; } $mode = (string) get_option('answerrails_privacy_logging_mode', 'anonymized'); // #174 (advanced_privacy promoted, decision 25 D4): a stored // 'identified' is honoured as-is, with no clamp. This // method is the single authority for the effective mode: unknown // stored values collapse to 'anonymized', and raw option reads for // PII decisions are a pinned regression. return ('identified' === $mode) ? 'identified' : 'anonymized'; } /** * IDEA helpers */ /** * Single source of truth for idea categories (key => translated label). * * Referenced by the extraction pipeline, admin validation, list-table * badges/filters and JS localisation — do not duplicate this list * elsewhere. Badge CSS classes are derived mechanically as * 'category-' + key with underscores as hyphens. * * Rows stored with a key not in this list render as "Uncategorised" * (grey badge) and can be re-categorised in the admin. Pre-commercial * policy (Jul 2026): no backward-compatibility keys or migrations. * * @since 3.4.0 * @return array category key => display label */ public static function idea_categories(): array { return [ 'feature_request' => __('Feature Request', 'answerrails'), 'bug_report' => __('Bug Report', 'answerrails'), // #151 (IDEA_CAPTURE_SENSITIVITY_DESIGN): demonstrated // unmet need — the visitor sought something this business does // not currently offer, stock, or cover. The knowledge-CONFIRMED // gap stream (the closed-world stack answers absence questions // WITHOUT handing off, so the deterministic unanswered_question // capture no longer sees them); together the two categories // feed #82 both gap streams. 'service_gap' => __('Service Gap', 'answerrails'), 'content_suggestion' => __('Content Suggestion', 'answerrails'), 'general_feedback' => __('General Feedback', 'answerrails'), 'sales_lead' => __('Sales Lead', 'answerrails'), 'unanswered_question' => __('Unanswered Question', 'answerrails'), ]; } /** * Whether a category key is valid. * * @since 3.4.0 */ public static function is_valid_idea_category(string $category): bool { return array_key_exists($category, self::idea_categories()); } /** * Categories the MODEL may assign to a captured idea — the enum for the * structured-output schema's `ideas` array and the validation set for * schema-native idea items (v3.5.0). Deliberately excludes the * pipeline-owned categories: sales_lead (lead pipeline, #70) and * unanswered_question (deterministic handoff capture). * * A named slice of idea_categories() — the registry above remains the * single source of truth; this method must never list a key that is not * in the registry. * * @since 3.5.0 * @return array category key => display label */ public static function model_idea_categories(): array { return array_intersect_key( self::idea_categories(), array_flip(['feature_request', 'bug_report', 'service_gap', 'content_suggestion', 'general_feedback']) ); } /** * Single source of truth for idea workflow statuses (key => label). * * Lifecycle: new (untriaged) → reviewed (seen, no commitment) → * open (committed, unresolved) → one of three terminals: * responded (customer reply sent) | implemented (change made) | * dismissed (won't pursue). * * Referenced by admin validation, list-table badges/views, bulk * actions and JS localisation — do not duplicate this list elsewhere. * Badge CSS classes are derived mechanically as 'status-' + key. * * @since 3.4.0 * @return array status key => display label */ public static function idea_statuses(): array { return [ 'new' => __('New', 'answerrails'), 'reviewed' => __('Reviewed', 'answerrails'), 'open' => __('Open', 'answerrails'), 'responded' => __('Responded', 'answerrails'), 'implemented' => __('Implemented', 'answerrails'), 'dismissed' => __('Dismissed', 'answerrails'), ]; } /** * Whether a status key is valid. * * @since 3.4.0 */ public static function is_valid_idea_status(string $status): bool { return array_key_exists($status, self::idea_statuses()); } public function log_idea_item(array $args): int { $idea = isset($args['idea']) ? sanitize_text_field($args['idea']) : ''; if ($idea === '') return 0; // Get and validate category against the shared registry $category = isset($args['category']) ? sanitize_text_field($args['category']) : 'general_feedback'; if (!self::is_valid_idea_category($category)) { $category = 'general_feedback'; } $pid = wp_insert_post([ 'post_type' => 'answerrails_idea', 'post_status' => 'publish', 'post_title' => '', 'post_content' => $idea, ], true); if (!is_wp_error($pid) && $pid) { update_post_meta($pid, '_answerrails_category', $category); update_post_meta($pid, '_answerrails_status', 'new'); update_post_meta($pid, '_answerrails_source', 'chat'); update_post_meta($pid, '_answerrails_captured_at', current_time('mysql', true)); // Optional whitelisted extra meta (v3.4.0). Used by deterministic // handoff capture to attach tuning context; unknown keys are // silently dropped so callers cannot write arbitrary meta. if (isset($args['meta']) && is_array($args['meta'])) { $allowed_meta = ['_answerrails_retrieval_score', '_answerrails_chatlog_ref']; foreach ($args['meta'] as $meta_key => $meta_value) { if (in_array($meta_key, $allowed_meta, true)) { $meta_value = is_numeric($meta_value) ? $meta_value + 0 : sanitize_text_field((string) $meta_value); update_post_meta($pid, $meta_key, $meta_value); } } } } return (int) $pid; } /** * Retention (identified logs only) */ public static function schedule_purge(): void { if (!wp_next_scheduled('answerrails_purge_old_logs')) { wp_schedule_event(time() + HOUR_IN_SECONDS, 'daily', 'answerrails_purge_old_logs'); } } /** * Query args for the retention purge, per storage scheme (#109). * * Pure and public so the purge CRITERIA are unit-testable: a purge that * queries only the legacy scheme (private posts, _ar_* metas) deletes * nothing, because live conversation logs are the Response_Service * scheme (publish posts, _answerrails_* metas). Only IDENTIFIED logs * are purged; anonymised logs hold no PII and are kept indefinitely. * * ⚠ **The two schemes compare the cutoff on DIFFERENT clocks, on purpose * (#291).** The `live` scheme converts it to the site clock, because * `_answerrails_last_activity` is written with `current_time('mysql')`; * comparing a GMT cutoff against those stamps expired a log when its age * passed `retention + offset`, which east of UTC left identified logs * standing past the retention the owner set. The `legacy` scheme is * deliberately NOT converted: it queries the `post_date_gmt` column, * which is already GMT, so converting would introduce the same skew the * live branch has just lost. * * @param string $scheme 'live' (Response_Service) or 'legacy' (_ar_*). * @param string $cutoff Y-m-d H:i:s GMT — purge identified logs older * than this (last activity for the live scheme, * creation date for the legacy one). * @return array WP_Query args. */ public static function purge_query_args(string $scheme, string $cutoff): array { // #119 legal hold: held records are preserved under the GDPR // Art. 17(3)(b) legal-obligation basis — the time-based purge skips // them exactly as the subject-based eraser does. Release // returns the record to normal retention behaviour. $not_held = [ 'key' => '_answerrails_legal_hold', 'compare' => 'NOT EXISTS', ]; if ('live' === $scheme) { // #291: site clock, to match the stamps this clause reads. $local_cutoff = AnswerRails_Privacy_Tools::site_local_cutoff($cutoff); return [ 'post_type' => self::POST_TYPE, 'post_status' => 'any', 'fields' => 'ids', 'posts_per_page' => 200, 'meta_query' => [ [ 'key' => '_answerrails_is_identified', 'value' => '1', ], [ 'key' => '_answerrails_last_activity', 'value' => $local_cutoff, 'compare' => '<', 'type' => 'DATETIME', ], $not_held, ], 'no_found_rows' => true, ]; } return [ 'post_type' => self::POST_TYPE, 'post_status' => 'private', 'fields' => 'ids', 'posts_per_page' => 200, 'meta_query' => [ [ 'key' => self::META_IDENT, 'value' => '1', ], $not_held, ], 'date_query' => [ [ 'column' => 'post_date_gmt', 'before' => $cutoff ], ], 'no_found_rows' => true, ]; } public static function purge_old_logs(): void { $days = (int) get_option('answerrails_privacy_retention_days', 180); if ($days <= 0) return; // Only delete identified logs older than retention; anonymised are // kept indefinitely. BOTH schemes are purged: 'live' is what the // chat writes today; 'legacy' covers pre-scheme installs (#109). $cutoff = gmdate('Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS); // #286 D4: reduce consent receipts past the SAME cutoff, BEFORE the // chatlog delete below. Receipts are append-only and were never // time-pruned (#120 D4), so in identified mode every consented // conversation left an IP, user agent and referrer behind // indefinitely once its chatlog aged out. // // The pass is age-driven over the whole receipt store and does NOT // consume the chatlog set purged below — see the method's docblock // for why that distinction is load-bearing, not a style choice. The // ordering here is stated so a later reader does not reintroduce a // dependency on a session id read off a post this run is about to // delete. AnswerRails_Privacy_Tools::reduce_expired_consent_receipts($cutoff); foreach (['live', 'legacy'] as $scheme) { $q = new \WP_Query(self::purge_query_args($scheme, $cutoff)); if ($q->have_posts()) { foreach ($q->posts as $pid) { wp_delete_post((int) $pid, true); } } } } }