diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
index 5f1b9aa..cdcae21 100644
--- a/.github/workflows/run-tests.yml
+++ b/.github/workflows/run-tests.yml
@@ -57,18 +57,44 @@ jobs:
- name: Install WordPress test suite
run: |
+ set -o pipefail
WP_TESTS_DIR=/tmp/wordpress-tests-lib
WP_CORE_DIR=/tmp/wordpress
+ # Download with retries: WordPress.org occasionally rate-limits or returns
+ # HTML instead of a tarball when under load, which produces
+ # "gzip: stdin: not in gzip format" — retrying clears it.
+ download_with_retry() {
+ local url="$1"
+ local dest="$2"
+ shift 2
+ for attempt in 1 2 3 4 5; do
+ if curl -sSL --retry 3 --retry-delay 5 --fail "$url" -o /tmp/wp-download.tar.gz; then
+ if tar tzf /tmp/wp-download.tar.gz > /dev/null 2>&1; then
+ tar xz --strip-components=1 -C "$dest" "$@" -f /tmp/wp-download.tar.gz
+ rm -f /tmp/wp-download.tar.gz
+ return 0
+ fi
+ fi
+ echo "Attempt $attempt failed for $url, sleeping before retry..."
+ sleep $((attempt * 5))
+ done
+ echo "Failed to download $url after 5 attempts" >&2
+ return 1
+ }
+
# Download WordPress
mkdir -p "$WP_CORE_DIR"
- curl -sL https://wordpress.org/latest.tar.gz | tar xz --strip-components=1 -C "$WP_CORE_DIR"
+ download_with_retry https://wordpress.org/latest.tar.gz "$WP_CORE_DIR"
# Download WordPress test suite
mkdir -p "$WP_TESTS_DIR"
WP_VERSION=$(php -r "require '$WP_CORE_DIR/wp-includes/version.php'; echo \$wp_version;")
- WP_BRANCH="${WP_VERSION%.*}"
- curl -sL "https://github.com/WordPress/wordpress-develop/archive/refs/heads/${WP_BRANCH}.tar.gz" | tar xz --strip-components=1 -C "$WP_TESTS_DIR" "wordpress-develop-${WP_BRANCH}/tests/phpunit/includes" "wordpress-develop-${WP_BRANCH}/tests/phpunit/data"
+ # Compute MAJOR.MINOR branch name (e.g. "6.7" from "6.7.1" or "6.7"; "7.0" from "7.0").
+ # The legacy `${WP_VERSION%.*}` form strips a `7.0` to just `7`, which is not a real
+ # wordpress-develop branch — they're always named MAJOR.MINOR.
+ WP_BRANCH=$(echo "$WP_VERSION" | awk -F. '{ if ($2 != "") print $1"."$2; else print $1 }')
+ download_with_retry "https://github.com/WordPress/wordpress-develop/archive/refs/heads/${WP_BRANCH}.tar.gz" "$WP_TESTS_DIR" "wordpress-develop-${WP_BRANCH}/tests/phpunit/includes" "wordpress-develop-${WP_BRANCH}/tests/phpunit/data"
# Move extracted contents to expected layout
mv "$WP_TESTS_DIR/tests/phpunit/includes" "$WP_TESTS_DIR/includes"
mv "$WP_TESTS_DIR/tests/phpunit/data" "$WP_TESTS_DIR/data"
diff --git a/PROMPT-CURSOR-SKILLS.md b/PROMPT-CURSOR-SKILLS.md
new file mode 100644
index 0000000..3a37041
--- /dev/null
+++ b/PROMPT-CURSOR-SKILLS.md
@@ -0,0 +1,40 @@
+# Cursor task: skills-management parity for escalated-wordpress (greenfield)
+
+Self-contained brief. Read fully before doing anything.
+
+## Goal
+Greenfield: implement the canonical Skills-management contract end-to-end on this WordPress plugin.
+
+**Tracking issue:** https://github.com/escalated-dev/escalated-wordpress/issues/55
+**Canonical contract:** https://github.com/escalated-dev/escalated-developer-context/blob/main/domain-model/skills-management.md
+**ADR:** https://github.com/escalated-dev/escalated-developer-context/blob/main/decisions/2026-05-13-skills-routing-explicit-mapping.md
+**Reference impl:** https://github.com/escalated-dev/escalated-laravel/pull/95 (closest PHP cousin) + https://github.com/escalated-dev/escalated-nestjs/pull/45
+
+## Current state
+No skills code today. WordPress plugin with REST controllers in `includes/Api/`. Activation hooks create custom tables.
+
+## Deliverables
+
+1. **Schema migration via the plugin's activation/upgrade flow** (look for an `Installer` / `Migrator` / `dbDelta` pattern): create `wp_escalated_skills`, `wp_escalated_agent_skills`, `wp_escalated_skill_routing_tags`, `wp_escalated_skill_routing_departments` (use the WordPress table prefix — these tables already use a prefixed `escalated_` namespace; mirror the existing convention).
+
+2. **REST controller** (`includes/Api/class-skill-controller.php`): 6 routes registered via `register_rest_route()` under `escalated/v1/admin/skills`. Use `permission_callback` to gate on admin role.
+
+3. **Data model layer**: WordPress plugins typically don't use ORMs — write small repository classes or use `$wpdb` directly, matching the conventions in the existing controllers. Wrap multi-table writes in `$wpdb->query('START TRANSACTION')` / `COMMIT` / `ROLLBACK`.
+
+4. **Routing service**: `class-skill-routing-service.php` implementing the explicit-mapping logic.
+
+5. **Inertia render**: the WordPress plugin serves the same shared Vue frontend. Confirm the admin route maps to `Escalated/Admin/Skills/Index` and `Form` page paths via whatever Inertia bridge the plugin uses.
+
+6. **Tests** (`tests/`): integration tests using WP_UnitTestCase (or whatever the repo uses).
+
+## Process
+
+1. `git checkout -b feat/admin-skills-management`.
+2. Read the contract + look at how the existing controllers structure CRUD.
+3. Implement and commit logically, reference #55.
+4. Push, open PR titled `feat(skills): admin skills management parity (#55)`.
+
+## Constraints
+- snake_case at the wire.
+- The plugin's coding standard (WPCS) — run `composer phpcs` if defined.
+- Stop after pushing. Don't include PROMPT-CURSOR-SKILLS.md in the PR.
diff --git a/README.md b/README.md
index 7ada06a..28a8293 100644
--- a/README.md
+++ b/README.md
@@ -185,6 +185,28 @@ If needed, set `WP_TESTS_DIR` to your local WordPress tests library path before
- **[Escalated for WordPress](https://github.com/escalated-dev/escalated-wordpress)** — WordPress plugin (you are here)
- **[Shared Frontend](https://github.com/escalated-dev/escalated)** — Vue 3 + Inertia.js UI components
+## Newsletters (optional, partial port)
+
+Schema, models, and renderer for the admin-only newsletter broadcast feature. Off by default — flip `escalated_newsletters_enabled` option (or pass `1` through the standard Escalated settings UI) to turn it on. WP-special: uses WP options, WP-Cron, and WP capabilities (see CLAUDE.md / spec).
+
+```php
+// Plug in a Markdown renderer (Parsedown, league/commonmark, etc.)
+add_filter('escalated_newsletter_markdown_renderer', function ($_, $md) {
+ return Parsedown::instance()->text($md);
+}, 10, 2);
+```
+
+Custom themes go in `templates/newsletter_themes/ '.implode(' ', preg_split('/\n{2,}/', $escaped)).'';
+ if (strpos($html, '
') !== false) { + return str_replace('', $pixel.'', $html); + } + + return $html.$pixel; + } +} diff --git a/includes/class-activator.php b/includes/class-activator.php index 811290f..ff5f749 100644 --- a/includes/class-activator.php +++ b/includes/class-activator.php @@ -13,6 +13,7 @@ class Activator public static function activate(): void { self::create_tables(); + self::create_newsletter_tables(); self::seed_permissions(); self::create_roles(); self::add_admin_caps(); @@ -56,6 +57,7 @@ public static function maybe_upgrade(): void } self::create_tables(); + self::create_newsletter_tables(); self::seed_permissions(); self::add_admin_caps(); self::insert_default_settings(); @@ -864,6 +866,129 @@ private static function insert_default_settings(): void } } + /** + * Optional newsletter system tables. Not registered to any specific + * activation flag — the data is harmless if unused. Behavior is gated by + * the `escalated_newsletters_enabled` option. + */ + public static function create_newsletter_tables(): void + { + global $wpdb; + require_once ABSPATH.'wp-admin/includes/upgrade.php'; + $prefix = $wpdb->prefix.'escalated_'; + $charset_collate = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$prefix}newsletter_lists ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + description TEXT NULL, + kind VARCHAR(16) NOT NULL, + filter_json TEXT NULL, + created_by BIGINT UNSIGNED NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY kind (kind), + KEY created_by (created_by) + ) $charset_collate;"; + dbDelta($sql); + + $sql = "CREATE TABLE {$prefix}newsletter_list_members ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + list_id BIGINT UNSIGNED NOT NULL, + contact_id BIGINT UNSIGNED NOT NULL, + added_at DATETIME NOT NULL, + added_by BIGINT UNSIGNED NULL, + PRIMARY KEY (id), + UNIQUE KEY list_contact (list_id, contact_id), + KEY contact_id (contact_id) + ) $charset_collate;"; + dbDelta($sql); + + $sql = "CREATE TABLE {$prefix}newsletter_templates ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + theme VARCHAR(64) NOT NULL DEFAULT 'default', + subject_template VARCHAR(998) NULL, + body_markdown LONGTEXT NOT NULL, + merge_fields_schema TEXT NULL, + created_by BIGINT UNSIGNED NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY theme (theme), + KEY created_by (created_by) + ) $charset_collate;"; + dbDelta($sql); + + $sql = "CREATE TABLE {$prefix}newsletters ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + subject VARCHAR(998) NOT NULL, + from_email VARCHAR(320) NOT NULL, + from_name VARCHAR(255) NULL, + reply_to VARCHAR(320) NULL, + target_list_id BIGINT UNSIGNED NOT NULL, + template_id BIGINT UNSIGNED NULL, + theme VARCHAR(64) NULL, + body_markdown LONGTEXT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'draft', + scheduled_at DATETIME NULL, + sent_at DATETIME NULL, + created_by BIGINT UNSIGNED NULL, + sent_by BIGINT UNSIGNED NULL, + summary_total INT UNSIGNED NOT NULL DEFAULT 0, + summary_sent INT UNSIGNED NOT NULL DEFAULT 0, + summary_opened INT UNSIGNED NOT NULL DEFAULT 0, + summary_clicked INT UNSIGNED NOT NULL DEFAULT 0, + summary_bounced INT UNSIGNED NOT NULL DEFAULT 0, + summary_complained INT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY status (status), + KEY scheduled_at (scheduled_at), + KEY status_sched (status, scheduled_at), + KEY created_by (created_by) + ) $charset_collate;"; + dbDelta($sql); + + $sql = "CREATE TABLE {$prefix}newsletter_deliveries ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + newsletter_id BIGINT UNSIGNED NOT NULL, + contact_id BIGINT UNSIGNED NOT NULL, + email_at_send VARCHAR(320) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending', + tracking_token VARCHAR(40) NOT NULL, + sent_at DATETIME NULL, + opened_at DATETIME NULL, + last_clicked_at DATETIME NULL, + clicks_count INT UNSIGNED NOT NULL DEFAULT 0, + bounce_reason TEXT NULL, + failure_reason TEXT NULL, + attempt_count SMALLINT UNSIGNED NOT NULL DEFAULT 0, + claimed_at DATETIME NULL, + is_test TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY tracking_token (tracking_token), + KEY nl_status (newsletter_id, status), + KEY contact_id (contact_id), + KEY status_claimed (status, claimed_at) + ) $charset_collate;"; + dbDelta($sql); + + // Add marketing_opt_out_at column to contacts if it doesn't exist. + $contacts_table = $prefix.'contacts'; + $col = $wpdb->get_var($wpdb->prepare( + "SHOW COLUMNS FROM `{$contacts_table}` LIKE %s", + 'marketing_opt_out_at' + )); + if (! $col) { + $wpdb->query("ALTER TABLE `{$contacts_table}` ADD COLUMN marketing_opt_out_at DATETIME NULL"); + $wpdb->query("ALTER TABLE `{$contacts_table}` ADD INDEX marketing_opt_out_at (marketing_opt_out_at)"); + } + } + /** * Schedule cron events and register custom cron intervals. */ diff --git a/templates/newsletter_themes/branded.php b/templates/newsletter_themes/branded.php new file mode 100644 index 0000000..74f4f27 --- /dev/null +++ b/templates/newsletter_themes/branded.php @@ -0,0 +1,42 @@ + + +
+ + +
+ + +
+