Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
40 changes: 40 additions & 0 deletions PROMPT-CURSOR-SKILLS.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>.php` and receive `$subject`, `$body` (pre-rendered safe HTML), `$unsubscribe_url`, `$view_in_browser_url`, `$brand` (associative array).

Ships:
- 5 new tables created by `Escalated\Activator::create_newsletter_tables()` (auto-called by the activator)
- `marketing_opt_out_at` column added to `escalated_contacts`
- 5 model wrappers under `includes/Models/Newsletter/`
- `includes/Services/Newsletter/NewsletterRenderer.php` — full renderer
- Two starter themes in `templates/newsletter_themes/{default,branded}.php`

Follow-up PR: WP-Cron tick for dispatcher, planner/tracker services, admin pages (custom or via the Inertia frontend), REST API endpoints for tracking + unsubscribe + view-in-browser, ESP webhook endpoints.

## License

MIT
44 changes: 44 additions & 0 deletions includes/Models/Newsletter/Newsletter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace Escalated\Models\Newsletter;

use Escalated\Escalated;

/**
* Static wrapper around the {prefix}newsletters table. Mirrors the WP
* convention used by other Escalated Models — static helpers around $wpdb.
*/
class Newsletter
{
public const STATUSES = ['draft', 'scheduled', 'sending', 'sent', 'paused', 'failed'];

public static function table(): string
{
return Escalated::table('newsletters');
}

public static function find(int $id): ?object
{
global $wpdb;

return $wpdb->get_row($wpdb->prepare('SELECT * FROM '.self::table().' WHERE id = %d', $id)) ?: null;
}

public static function create(array $attrs): int
{
global $wpdb;
$now = current_time('mysql');
$wpdb->insert(
self::table(),
array_merge(['status' => 'draft', 'created_at' => $now, 'updated_at' => $now], $attrs)
);

return (int) $wpdb->insert_id;
}

public static function update(int $id, array $attrs): void
{
global $wpdb;
$wpdb->update(self::table(), array_merge($attrs, ['updated_at' => current_time('mysql')]), ['id' => $id]);
}
}
25 changes: 25 additions & 0 deletions includes/Models/Newsletter/NewsletterDelivery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace Escalated\Models\Newsletter;

use Escalated\Escalated;

class NewsletterDelivery
{
public const STATUSES = ['pending', 'queued', 'sent', 'bounced', 'complained', 'suppressed', 'failed'];

public static function table(): string
{
return Escalated::table('newsletter_deliveries');
}

public static function find_by_token(string $token): ?object
{
global $wpdb;

return $wpdb->get_row($wpdb->prepare(
'SELECT * FROM '.self::table().' WHERE tracking_token = %s',
$token
)) ?: null;
}
}
36 changes: 36 additions & 0 deletions includes/Models/Newsletter/NewsletterList.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace Escalated\Models\Newsletter;

use Escalated\Escalated;

class NewsletterList
{
public const KIND_STATIC = 'static';

public const KIND_DYNAMIC = 'dynamic';

public static function table(): string
{
return Escalated::table('newsletter_lists');
}

public static function find(int $id): ?object
{
global $wpdb;

return $wpdb->get_row($wpdb->prepare('SELECT * FROM '.self::table().' WHERE id = %d', $id)) ?: null;
}

public static function create(array $attrs): int
{
global $wpdb;
$now = current_time('mysql');
$wpdb->insert(
self::table(),
array_merge(['created_at' => $now, 'updated_at' => $now], $attrs)
);

return (int) $wpdb->insert_id;
}
}
13 changes: 13 additions & 0 deletions includes/Models/Newsletter/NewsletterListMember.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Escalated\Models\Newsletter;

use Escalated\Escalated;

class NewsletterListMember
{
public static function table(): string
{
return Escalated::table('newsletter_list_members');
}
}
20 changes: 20 additions & 0 deletions includes/Models/Newsletter/NewsletterTemplate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace Escalated\Models\Newsletter;

use Escalated\Escalated;

class NewsletterTemplate
{
public static function table(): string
{
return Escalated::table('newsletter_templates');
}

public static function find(int $id): ?object
{
global $wpdb;

return $wpdb->get_row($wpdb->prepare('SELECT * FROM '.self::table().' WHERE id = %d', $id)) ?: null;
}
}
Loading