# ─────────────────────────────────────────────────────────────────────────
# WB Listora (FREE) — 1.2.0 PHASE 2 — FINAL execution manifest
# Owner-approved 2026-06-07 (8 items, DEC-4 pulls HivePress back into 1.2.0).
# Supersedes the hand-sketch that previously lived at this path.
# Runs on top of autovap/wb-listora/integration (phase-1 1.2.0 waves merged:
# analytics-lite, background AS import, tap targets, alt fallbacks, eager migrator).
# Rationale + reuse map + contract risks: plan/1.2.0-phase2-rationale.md
#
# MANIFEST-FIRST: every code direction names the EXISTING class/helper/hook it
# reuses and what it must NOT re-implement. Sources verified against
# audit/manifest.json + live source on 2026-06-07 (see rationale).
# ─────────────────────────────────────────────────────────────────────────
version: 1

target:
  type: plugin
  slug: wb-listora
  path: "/Users/varundubey/Local Sites/wb-listora/app/public/wp-content/plugins/wb-listora"
  repo: wbcomdesigns/wb-listora
  local_wp_site: wb-listora
  site_url: http://wb-listora.local
  pair: wb-listora-pro

goal: >
  1.2.0 phase 2 (Free): close the buyer-expectation gaps a directory owner
  assumes just work, all additive and reusing Free's existing surface. Honest
  feature catalog; review-reminder emails (AS, idempotent, opt-out via the
  existing _listora_notify_{event} prefs); Open-now/Closed badge from the
  listora_hours table; WP privacy-tools exporter + eraser for review/claim/
  favorite PII; admin email-template editor MVP (one non-autoloaded option read
  through the EXISTING wb_listora_email_subject_{event}/_content_{event}
  filters); HivePress migrator (Migration_Base, context:migration); an additive
  post-search action for Pro search-analytics (Free-first); and bulk-edit
  listing actions reusing bulk-moderate semantics + Status_Manager + Featured.
  Additive minor only (production rules 7-9); 100k-scale (bounded indexed
  queries, AS for long-running, no autoloaded fat options).

# ── CONTRACTS ──────────────────────────────────────────────────────────────
# Public surface the engine must NOT silently break. New additive surface this
# release is listed under migrations[] (the sanctioned-change allow-list).
contracts:
  rest_routes:
    - namespace: "listora/v1"
      routes:
        - "/listings/bulk-moderate"   # item 8 reuses these semantics; route unchanged
        - "/search"
  hooks:
    actions:
      - "wb_listora_listing_submitted"        # item 6 fires with context:migration (existing sentinel)
      - "wb_listora_after_bulk_moderate"      # item 8 reuses; signature unchanged
      - "wb_listora_listing_status_changed"
    filters:
      - "wb_listora_email_subject"            # item 5 reads through the dynamic _{event} variant
      - "wb_listora_email_content"            # item 5 reads through the dynamic _{event} variant
      - "wb_listora_search_args"
      - "wb_listora_search_results"           # EXISTING FILTER (REST). Item 7 must NOT collide — see migrations[]
  options:
    - "wb_listora_settings"                   # non-autoloaded (AUD-F6); item 5 option follows the same pattern
  db_schema:
    - table: "listora_hours"                  # item 3 reads; columns unchanged
      columns: ["listing_id", "day_of_week", "open_time", "close_time", "is_closed", "is_24h", "timezone"]
    - table: "listora_reviews"                # item 4 eraser anonymizes
    - table: "listora_claims"                 # item 4 eraser deletes
    - table: "listora_favorites"              # item 4 eraser deletes
  email_output:
    - "wb_listora_email_subject"
    - "wb_listora_email_content"

# ── MIGRATIONS (sanctioned additive changes — the allow-list) ───────────────
# Everything additive this release is declared here so the contract verifier
# treats it as expected, not a breach.
migrations:
  - kind: hook
    to: "wb_listora_search_resolved"
    reason: >
      Item 7. NEW additive action fired from the search engine after each query
      resolves, signature ( array $args, int $total, array $context ), so Pro
      search-analytics can record keywords + zero-result queries with no
      coupling. NOT named wb_listora_search_results — that name is ALREADY a
      REST-only filter ( $response_data, $args, $request ) at
      class-search-controller.php:315; reusing it would be a signature collision.
      Fired from class-search-engine.php::search() so it covers BOTH the REST
      and SSR/block paths (the existing filter only covers REST).
  - kind: hook
    to: "wb_listora_review_reminder"
    reason: >
      Item 2. New notification EVENT key (not a raw hook) registered in the
      Notifications event map so the reminder flows through send()/the existing
      wb_listora_email_subject_{event}/_content_{event} filters and the existing
      _listora_notify_{event} per-user opt-out gate.
  - kind: option
    to: "wb_listora_email_templates"
    reason: >
      Item 5. ONE new NON-AUTOLOADED option ( update_option(..., false) per the
      AUD-F6 pattern ) holding admin-edited subject+body per event. Read back via
      add_filter on the EXISTING wb_listora_email_subject_{event} /
      wb_listora_email_content_{event} filters — no template-file edits, no fat
      autoloaded option.

# ── STANDARDS ────────────────────────────────────────────────────────────────
standards:
  build_skills:  [wp-plugin-development, ux-foundation, wp-i18n]
  audit_skills:  [ux-audit, action-audit, documentation]
  phpstan_level: 6
  wpcs_standard: WordPress-Extra

# ── GATES ────────────────────────────────────────────────────────────────────
gates:
  default_static:     [php-lint, wpcs, phpstan]
  default_functional: [browser-smoke, qa-suite]

# ── JOURNEYS (expected flow first — incl. the negative path) ─────────────────
journeys:
  - id: catalog-reconcile
    role: admin
    steps:
      - "open docs/website/feature-catalog.md and feature-matrix.md side by side"
      - "every Pro feature in feature-matrix.md is present in feature-catalog.md (9 currently missing: Quick View, Infinite Scroll, Comparison, Audit Log, Outgoing Webhooks, SEO Pages, Google Places import, BuddyPress, Visual Importer)"
      - "catalog entries match the catalog's existing style; no em-dashes; no emoji; internal links resolve"

  - id: review-reminder-optout
    role: subscriber
    steps:
      - "seed an engagement (favorite/contact) and run the review-reminder cron via wp-cli"
      - "the user receives exactly one reminder email; the standard envelope renders; the opt-out link is present"
      - "re-run the cron — NO duplicate email (idempotent per user+listing)"
      - "negative: a user with the reminder pref opted out (_listora_notify_review_reminder = 0) receives nothing; debug.log clean"

  - id: open-now-badge
    role: visitor
    steps:
      - "a listing whose listora_hours rows cover NOW shows an 'Open now' badge on the detail page; one closed-now shows 'Closed'"
      - "overnight spans (e.g. 22:00-02:00) and the listing's stored timezone are honored"
      - "negative: a listing with NO hours rows shows NEITHER badge — no fatal, no empty chip"
      - "badge correct at 390px and 1280px; token colors only; dark mode legible; aria/alt text present"

  - id: privacy-export-erase
    role: admin
    steps:
      - "Tools -> Export Personal Data for a user with reviews + claims + favorites: the export includes a WB Listora group with that data, paginated per the WP privacy API"
      - "Tools -> Erase Personal Data: reviews anonymized (listing rating aggregates stay consistent), claims + favorites rows deleted"
      - "negative: a user with NO Listora PII produces an empty-but-valid exporter/eraser response (no fatal)"
      - "debug.log clean"

  - id: email-editor-roundtrip
    role: admin
    steps:
      - "Listora -> Settings -> Emails lists every notification event with an editable subject + body (textarea + allowed-placeholder legend)"
      - "edit subject + body for one event, save; the test-send (existing Notifications::send_test) renders the edited values"
      - "reset-to-default restores the shipped template; an UNEDITED event still renders from its template file (filter returns unchanged)"
      - "negative: paste a <script> into the body — kses strips it on save; reloaded value is sanitized"
      - "390px admin layout usable; ux-foundation card pattern; no native alert/confirm"

  - id: hivepress-migration
    role: admin
    steps:
      - "MODE-AWARE entry point (corrected after a false wave-6 block: Pro INTENTIONALLY replaces Free's Migration tab with its Visual Importer via the wb_listora_settings_nav_groups filter). On a COMBO site verify via WP-CLI: wp listora migrate --from=hivepress detects the source (or reports detect=false cleanly with no data) and the CLI available-sources help lists hivepress. On a FREE-ONLY check (Pro deactivated) the Settings -> Migration tab lists HivePress. Pro's Visual Importer surface is covered by Pro wave-9, not here."
      - "run the migration: listings, categories, locations, images, and reviews import with correct field mapping per audit/architecture/competitor-schemas/hivepress.md"
      - "each imported listing fires wb_listora_listing_submitted with context source=migration, so Notifications does NOT email the admin per legacy listing (sentinel: regression/migrator-context-arg.md)"
      - "negative: no HivePress data present -> detect() returns false, the screen says so, no fatal"

  - id: bulk-edit-listings
    role: admin
    steps:
      - "on the listings admin table, select several listings and choose a Listora bulk action: Approve, Reject, Assign category, or Feature/Unfeature"
      - "approve/reject transition status through Status_Manager (so notifications/webhooks fire); feature/unfeature route through the Featured service (so Pro credit rules apply)"
      - "a per-row result notice reports successes + failures; the action is capability-gated and nonce-protected"
      - "negative: a non-capable user does not see the actions; an invalid transition is reported as failed, not silently applied; batch bounded to 100/req"

# ── WAVES (9-phase ordering; same-wave scopes disjoint for parallel safety) ──
waves:

  # Wave 5 — quick wins. No code dependency between the three; scopes disjoint.
  # core-logic + frontend + docs phases collapsed (non-overlapping file scope).
  - id: wave-5-quick-wins
    depends_on: []
    functional_gates: [browser-smoke]
    journeys: [catalog-reconcile, review-reminder-optout, open-now-badge]
    directions:
      - id: f5-feature-catalog
        title: >
          DOCS-ONLY. Reconcile docs/website/feature-catalog.md against
          feature-matrix.md: add the 9 Pro features present in the matrix but
          missing from the catalog (Quick View, Infinite Scroll, Comparison,
          Audit Log, Outgoing Webhooks, SEO Pages, Google Places import,
          BuddyPress, Visual Importer) in the catalog's existing entry style.
          REUSE the catalog's heading/section conventions; do NOT invent new
          feature claims — mirror feature-matrix.md wording. No em-dashes, no
          emoji.
        journey: customer-docs
        scope: ["docs/website/feature-catalog.md"]
        skills: [documentation]
        static_gates: [docs-lint, link-check]
      - id: f5-review-reminder
        title: >
          Review-reminder as a NEW notification EVENT (wb_listora_review_reminder)
          on the EXISTING Notifications pipeline. REUSE: Notifications::send() +
          the standard email envelope + Email_Body_Formatter; the
          _listora_notify_{event} per-user opt-out gate (class-notifications.php:865/893,
          default-on); Cron_Scheduler + Action Scheduler for the bounded batch
          sweep. Schedule one reminder per user+listing, idempotent via a
          de-dupe meta/AS-uniqueness guard (NOT a new table). MUST NOT
          re-implement send()/mail/opt-out logic or autoload a fat option. New
          template under templates/emails/ + a reminder row in the dashboard
          tab-profile prefs (entry points: cron writer + tab-profile read).
        journey: develop
        scope: ["includes/workflow/**", "templates/emails/**"]
        skills: [wp-plugin-development, wp-i18n]
        static_gates: [php-lint, wpcs, phpstan, wiring]
      - id: f5-open-now-badge
        title: >
          'Open now / Closed' badge on the listing-detail page computed from the
          listora_hours table (cols day_of_week/open_time/close_time/is_closed/
          is_24h/timezone, index idx_open). Site/row timezone aware; handles
          overnight spans; MISSING rows -> no badge (no fatal, no empty chip).
          REUSE the existing badge primitive in src/components + design tokens;
          fold ONE bounded indexed query into the existing detail render (no
          per-listing N+1). MUST NOT add a table or an autoloaded option.
          aria/alt text required.
        journey: develop
        scope: ["blocks/listing-detail/**", "templates/blocks/listing-detail/**", "src/components/badge*"]
        skills: [wp-plugin-development, ux-foundation]
        static_gates: [php-lint, wpcs, phpstan, ux-audit]

  # Wave 6 — search hook (Free-first) + HivePress migrator. Independent of w5;
  # depends only on a green base. Scopes disjoint (search/ vs import-export/).
  - id: wave-6-extensibility
    depends_on: [wave-5-quick-wins]
    functional_gates: [browser-smoke, qa-suite]
    journeys: [hivepress-migration]
    directions:
      - id: f6-search-resolved-hook
        title: >
          NEW additive action wb_listora_search_resolved( array $args, int $total,
          array $context ) fired from Search_Engine::search() AFTER each query
          resolves, covering BOTH the REST and SSR/block paths, so Pro
          search-analytics can listen with no coupling (Free-first upscale rule).
          DO NOT reuse the name wb_listora_search_results — it is ALREADY a
          REST-only FILTER ($response_data,$args,$request) at
          class-search-controller.php:315; collision would break listeners.
          Add the new hook row to audit/manifest.json hooks_fired in the same
          change. This is genuinely new code (no equivalent action fires today —
          verified against hooks_fired).
        journey: develop
        scope: ["includes/search/**"]
        skills: [wp-plugin-development]
        static_gates: [php-lint, wpcs, phpstan]
      - id: f6-hivepress-migrator
        title: >
          HivePress migrator in Free at includes/import-export/class-hivepress-migrator.php
          extending Migration_Base (implement the abstracts detect(),
          get_source_count(), get_source_ids(), migrate_listing(); override
          detect_source_fields()/get_default_mapping()/extract_for_preview() per
          the schema audit at audit/architecture/competitor-schemas/hivepress.md).
          Listings, categories, locations, images, reviews. Fire
          wb_listora_listing_submitted with a 4th $context arg source=migration
          (sentinel: docs/qa/journeys/regression/migrator-context-arg.md).
          REUSE: Term_Helper for taxonomy terms, the universal importer plumbing,
          and the Background_Import AS pipeline for large runs (bounded batches).
          REGISTER via the existing wb_listora_get_migrators() in
          migration-helpers.php. MUST NOT duplicate Term_Helper or the AS batch
          loop. Update the manifest competitor-migrator list in the same change.
        journey: develop
        scope: ["includes/import-export/**"]
        skills: [wp-plugin-development]
        static_gates: [php-lint, wpcs, phpstan, wiring]

  # Wave 7 — privacy tools (net-new, no existing exporters/erasers).
  - id: wave-7-privacy
    depends_on: [wave-5-quick-wins]
    functional_gates: [browser-smoke, qa-suite]
    journeys: [privacy-export-erase]
    directions:
      - id: f7-privacy-tools
        title: >
          WP privacy-tools EXPORTER + ERASER for Listora PII. Register via the
          core wp_privacy_personal_data_exporters / wp_privacy_personal_data_erasers
          filters (NONE exist today — net new, no dup risk). Exporter: reviews +
          claims + favorites for a given email, paginated per the WP privacy API
          (page param, done flag). Eraser: ANONYMIZE reviews (preserve listing
          rating aggregates) and DELETE favorites + claims rows, paginated/batched.
          REUSE the existing review/claim/favorite read paths and the listora_
          table accessors; MUST NOT bypass the rating-aggregate recompute. Wire
          registration from includes/class-plugin.php (single registration site).
        journey: develop
        scope: ["includes/privacy/**", "includes/class-plugin.php"]
        skills: [wp-plugin-development]
        static_gates: [php-lint, wpcs, phpstan, wiring]

  # Wave 8 — admin email-template editor MVP. Admin UI; depends on the reminder
  # event existing (w5) so the new event appears in the editor list.
  - id: wave-8-email-editor
    depends_on: [wave-5-quick-wins]
    functional_gates: [browser-smoke, qa-suite]
    journeys: [email-editor-roundtrip]
    directions:
      - id: f8-email-editor-mvp
        title: >
          Admin email-template editor MVP: a Settings -> Emails screen listing
          EVERY notification event (from the Notifications event map, including
          the new review_reminder) with an editable subject + body (textarea +
          allowed-placeholder legend) and per-event reset-to-default. Persist to
          ONE NON-AUTOLOADED option wb_listora_email_templates
          (update_option(..., false), AUD-F6 pattern). Read the saved values
          back by add_filter on the EXISTING wb_listora_email_subject_{event}
          ($subject,$vars) / wb_listora_email_content_{event} ($body,$vars)
          filters — NO template-file edits. Preview via the EXISTING
          Notifications::send_test(). kses-sanitize body on save. REUSE the
          ux-foundation settings-card pattern + admin header; render under the
          Notifications/Emails settings tab. MUST NOT add a rich builder, a new
          option per event, or an autoloaded option. 390px usable.
        journey: develop
        scope: ["includes/admin/class-email-templates-page.php", "includes/admin/class-settings-page.php", "assets/css/admin/email-templates*", "assets/js/admin/email-templates*"]
        skills: [wp-plugin-development, ux-foundation, wp-i18n]
        static_gates: [php-lint, wpcs, phpstan, ux-audit, wiring]

  # Wave 9 — bulk-edit listing actions. Admin list-table; depends on the email
  # editor wave only to keep includes/admin scope serialized (no glob overlap
  # across parallel waves). Reuses bulk-moderate semantics + Status_Manager +
  # Featured.
  - id: wave-9-bulk-edit
    depends_on: [wave-8-email-editor]
    functional_gates: [browser-smoke, qa-suite]
    journeys: [bulk-edit-listings]
    directions:
      - id: f9-bulk-edit
        title: >
          Bulk-edit listing actions on the WP listings table beyond core:
          bulk Approve/Reject, bulk Assign-category, bulk Feature/Unfeature.
          REUSE: the bulk-moderate transition semantics already in
          Listings_Controller::bulk_moderate (do NOT duplicate transition logic);
          Status_Manager::is_valid_transition()/get_statuses() for approve/reject
          so notifications + webhooks fire; the Featured service
          (Featured::feature_listing/unfeature_listing) so Pro credit rules apply;
          Term_Helper for category assignment. Capability-gated + nonce-protected;
          bounded batches (100/req, same as bulk-moderate); per-row result notice.
          Wire via the WP list-table bulk_actions / handle_bulk_actions hooks in
          a NEW class-listing-bulk-actions.php; do NOT bloat class-listing-columns.php.
        journey: develop
        scope: ["includes/admin/class-listing-bulk-actions.php", "assets/js/admin/bulk-edit*"]
        skills: [wp-plugin-development, ux-foundation]
        static_gates: [php-lint, wpcs, phpstan, wiring]
