RcmEmailAutomation / sa-orchestration /includes /class-sa-leaf-link.php
NujacsaintS's picture
Pre-stage: roll forward sa-orchestration/ with v0.8.0 MVP leaf-link
5a14210
Raw
History Blame Contribute Delete
15.1 kB
<?php
defined( 'ABSPATH' ) || exit;
/**
* Leaf↔Root Link — v0.8.0 MVP
*
* Two halves in one class, ship together in every install:
*
* ROOT half — REST endpoint /sa-orch/v1/root/assignments
* Returns Fluent Boards tasks where the authenticated user is
* an assignee (via fbs_relations object_type='task_assignee').
* Auth: WP application password (Basic auth) or session nonce.
*
* LEAF half — Admin page (SA-Orchestration → Remote Roots)
* Stores ONE remote root config in wp_options (URL + app
* password). Manual "Fetch Now" button calls the configured
* root's /assignments endpoint and renders results in a
* table on the same admin page.
*
* MVP scope (locked):
* - One root per leaf (multi-root deferred to post-MVP)
* - No new tables — single wp_option for config
* - Manual fetch only; no cadence, heartbeat, or cron
* - No Fluent Support ticket creation; just list assignments on screen
* - No outbound curated updates; bidirectional sync is post-MVP
*
* The closed loop: developer self-assigns on Fluent Boards on the dev root
* → leaf admin clicks "Fetch Now" → assignment appears in the leaf's
* Remote Roots admin page.
*
* Doctrine: leaf-initiated, always up; the dev root never reaches into
* the leaf. Per Board #40 invariants.
*/
class SA_Orch_Leaf_Link {
const SUBMENU_SLUG = 'sa-orchestration-remote-roots';
const OPTION_KEY = 'sa_orch_remote_root'; // single-root MVP storage
const REST_TIMEOUT = 15;
/* ===================================================================
* Hooks
* =================================================================== */
public static function register_routes() {
// ROOT half — serves assignments to authenticated callers.
register_rest_route( 'sa-orch/v1', '/root/assignments', [
'methods' => 'GET',
'callback' => [ __CLASS__, 'rest_assignments' ],
'permission_callback' => function () {
return current_user_can( 'read' ); // any logged-in user; the
// join filters by their id
},
'args' => [
'since' => [ 'type' => 'string', 'required' => false ],
],
] );
// LEAF half — manual fetch trigger (callable by admin from the page).
register_rest_route( 'sa-orch/v1', '/leaf/fetch-now', [
'methods' => 'POST',
'callback' => [ __CLASS__, 'rest_fetch_now' ],
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
] );
}
public static function register_menu() {
add_submenu_page(
SA_Orch_Admin::PARENT_SLUG,
'Remote Roots',
'Remote Roots',
SA_Orch_Admin::CAP,
self::SUBMENU_SLUG,
[ __CLASS__, 'render_page' ]
);
}
/* ===================================================================
* ROOT HALF — assignments endpoint
* =================================================================== */
public static function rest_assignments( WP_REST_Request $req ) {
global $wpdb;
$user_id = get_current_user_id();
if ( $user_id <= 0 ) {
return new WP_REST_Response( [
'error' => 'unauthenticated',
'message' => 'No authenticated user resolved.',
], 401 );
}
// Filter by 'since' (last_modified > since); optional.
$since = $req->get_param( 'since' );
$where_since = '';
$params = [ $user_id ];
if ( $since && preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?$/', (string) $since ) ) {
$where_since = " AND t.updated_at > %s";
$params[] = str_replace( 'T', ' ', rtrim( (string) $since, 'Z' ) );
}
// Fluent Boards assignment join: fbs_relations.object_type = 'task_assignee',
// object_id = task_id, foreign_id = wp user_id.
$sql = "
SELECT
t.id AS root_task_id,
t.board_id,
t.title,
t.description,
t.stage_id,
t.status,
t.priority,
t.created_at,
t.updated_at,
t.last_completed_at,
b.title AS board_title,
bt.title AS stage_title
FROM {$wpdb->prefix}fbs_relations r
JOIN {$wpdb->prefix}fbs_tasks t ON r.object_id = t.id
LEFT JOIN {$wpdb->prefix}fbs_boards b ON t.board_id = b.id
LEFT JOIN {$wpdb->prefix}fbs_board_terms bt ON t.stage_id = bt.id
WHERE r.object_type = 'task_assignee'
AND r.foreign_id = %d
AND t.archived_at IS NULL
{$where_since}
ORDER BY t.updated_at DESC
LIMIT 100
";
$rows = $wpdb->get_results( $wpdb->prepare( $sql, $params ), ARRAY_A );
if ( $rows === null ) {
return new WP_REST_Response( [
'error' => 'query_failed',
'message' => 'Database query failed: ' . $wpdb->last_error,
], 500 );
}
// Trim description for transport efficiency. Full description fetched
// on demand by leaf when dev clicks into a specific assignment.
$out = [];
foreach ( $rows as $r ) {
$desc = (string) $r['description'];
$desc_excerpt = function_exists( 'mb_substr' )
? mb_substr( wp_strip_all_tags( $desc ), 0, 800 )
: substr( wp_strip_all_tags( $desc ), 0, 800 );
$out[] = [
'root_task_id' => (int) $r['root_task_id'],
'board_id' => (int) $r['board_id'],
'board_title' => (string) $r['board_title'],
'stage_id' => (int) $r['stage_id'],
'stage_title' => (string) $r['stage_title'],
'status' => (string) $r['status'],
'priority' => (string) $r['priority'],
'title' => (string) $r['title'],
'description_excerpt' => $desc_excerpt,
'created_at' => (string) $r['created_at'],
'updated_at' => (string) $r['updated_at'],
'last_completed_at' => $r['last_completed_at'],
];
}
return new WP_REST_Response( [
'actor' => [
'wp_user_id' => $user_id,
'login' => ( $u = get_user_by( 'id', $user_id ) ) ? $u->user_login : null,
],
'count' => count( $out ),
'fetched_at' => current_time( 'mysql', true ),
'assignments' => $out,
], 200 );
}
/* ===================================================================
* LEAF HALF — fetch logic
* =================================================================== */
/**
* Fetch assignments from the configured root.
* Returns either a parsed payload or a WP_Error.
*/
public static function fetch_remote_assignments() {
$cfg = get_option( self::OPTION_KEY, [] );
if ( empty( $cfg['url'] ) || empty( $cfg['app_user'] ) || empty( $cfg['app_password'] ) ) {
return new WP_Error( 'not_configured', 'Remote root not configured. Set URL, username, and application password first.' );
}
$url = rtrim( (string) $cfg['url'], '/' ) . '/wp-json/sa-orch/v1/root/assignments';
$auth_header = 'Basic ' . base64_encode( $cfg['app_user'] . ':' . str_replace( ' ', '', $cfg['app_password'] ) );
$resp = wp_remote_get( $url, [
'headers' => [ 'Authorization' => $auth_header ],
'timeout' => self::REST_TIMEOUT,
] );
if ( is_wp_error( $resp ) ) {
return $resp;
}
$code = (int) wp_remote_retrieve_response_code( $resp );
$body = (string) wp_remote_retrieve_body( $resp );
if ( $code < 200 || $code >= 300 ) {
return new WP_Error( 'remote_error', "HTTP $code from root: " . substr( $body, 0, 240 ) );
}
$payload = json_decode( $body, true );
if ( ! is_array( $payload ) ) {
return new WP_Error( 'parse_error', 'Root response was not valid JSON: ' . substr( $body, 0, 240 ) );
}
// Cache the last fetch in option for the admin page render.
update_option( 'sa_orch_remote_root_last_fetch', [
'at' => current_time( 'mysql', true ),
'payload' => $payload,
], false /* not autoloaded */ );
return $payload;
}
public static function rest_fetch_now( WP_REST_Request $req ) {
$r = self::fetch_remote_assignments();
if ( is_wp_error( $r ) ) {
return new WP_REST_Response( [
'error' => $r->get_error_code(),
'message' => $r->get_error_message(),
], 502 );
}
return new WP_REST_Response( $r, 200 );
}
/* ===================================================================
* LEAF HALF — admin page render
* =================================================================== */
public static function render_page() {
if ( ! current_user_can( SA_Orch_Admin::CAP ) ) wp_die( 'Insufficient permissions.' );
$error = '';
$notice = '';
// Handle save
if ( isset( $_POST['sa_orch_save_root'] ) && check_admin_referer( 'sa_orch_save_root' ) ) {
$cfg = [
'url' => esc_url_raw( trim( wp_unslash( $_POST['root_url'] ?? '' ) ) ),
'app_user' => sanitize_text_field( wp_unslash( $_POST['app_user'] ?? '' ) ),
'app_password' => trim( wp_unslash( $_POST['app_password'] ?? '' ) ),
];
update_option( self::OPTION_KEY, $cfg, false );
$notice = 'Remote root saved.';
}
// Handle fetch button
if ( isset( $_POST['sa_orch_fetch_now'] ) && check_admin_referer( 'sa_orch_fetch_now' ) ) {
$r = self::fetch_remote_assignments();
if ( is_wp_error( $r ) ) {
$error = 'Fetch failed: ' . $r->get_error_message();
} else {
$notice = 'Fetched ' . (int) $r['count'] . ' assignment(s).';
}
}
$cfg = get_option( self::OPTION_KEY, [] );
$last_fetch = get_option( 'sa_orch_remote_root_last_fetch', [] );
$payload = $last_fetch['payload'] ?? null;
$assignments = is_array( $payload ) ? ( $payload['assignments'] ?? [] ) : [];
echo '<div class="wrap">';
echo '<h1>Remote Roots</h1>';
echo '<p>Pull assignments from a remote SA-Orchestration root (e.g., the dev root). Leaf-initiated; outbound HTTPS only.</p>';
if ( $error !== '' ) echo '<div class="notice notice-error"><p>' . esc_html( $error ) . '</p></div>';
if ( $notice !== '' ) echo '<div class="notice notice-success"><p>' . esc_html( $notice ) . '</p></div>';
// Configuration form
echo '<h2>Configuration (single root, MVP)</h2>';
echo '<form method="post">';
wp_nonce_field( 'sa_orch_save_root' );
echo '<table class="form-table">';
echo '<tr><th>Root URL</th><td><input type="url" name="root_url" class="regular-text" placeholder="https://sleeperagendev.wpenginepowered.com" value="' . esc_attr( $cfg['url'] ?? '' ) . '" required></td></tr>';
echo '<tr><th>App Username</th><td><input type="text" name="app_user" class="regular-text" placeholder="user@example.com" value="' . esc_attr( $cfg['app_user'] ?? '' ) . '" required></td></tr>';
echo '<tr><th>App Password</th><td><input type="password" name="app_password" class="regular-text" placeholder="XXXX XXXX XXXX XXXX XXXX XXXX" value="' . esc_attr( $cfg['app_password'] ?? '' ) . '" required>';
echo '<p class="description">Generate at <code>{root}/wp-admin/profile.php</code> → Application Passwords. Stored in wp_options (not autoloaded). Revoke from same page anytime.</p></td></tr>';
echo '</table>';
echo '<p><button type="submit" name="sa_orch_save_root" value="1" class="button">Save Remote Root</button></p>';
echo '</form>';
// Fetch button
echo '<h2>Pull Assignments</h2>';
echo '<form method="post">';
wp_nonce_field( 'sa_orch_fetch_now' );
echo '<p><button type="submit" name="sa_orch_fetch_now" value="1" class="button button-primary">Fetch Now</button>';
if ( ! empty( $last_fetch['at'] ) ) {
echo ' <span style="margin-left:1em;color:#666;">Last fetched: ' . esc_html( $last_fetch['at'] ) . ' UTC</span>';
}
echo '</p>';
echo '</form>';
// Render assignments
if ( $payload && isset( $payload['count'] ) ) {
$actor = $payload['actor'] ?? [];
echo '<h2>Assignments (' . (int) $payload['count'] . ')</h2>';
echo '<p style="color:#666;">Authenticated to root as: <code>' . esc_html( $actor['login'] ?? '' ) . '</code> (wp_user_id=' . (int) ( $actor['wp_user_id'] ?? 0 ) . ')</p>';
if ( empty( $assignments ) ) {
echo '<p>No assignments found for this user on the configured root.</p>';
} else {
echo '<table class="widefat striped"><thead><tr>';
echo '<th>Root Task ID</th><th>Board</th><th>Stage</th><th>Status</th><th>Priority</th><th>Title</th><th>Updated</th>';
echo '</tr></thead><tbody>';
foreach ( $assignments as $a ) {
echo '<tr>';
echo '<td>' . (int) $a['root_task_id'] . '</td>';
echo '<td>' . esc_html( $a['board_title'] ) . ' (#' . (int) $a['board_id'] . ')</td>';
echo '<td>' . esc_html( $a['stage_title'] ) . '</td>';
echo '<td>' . esc_html( $a['status'] ) . '</td>';
echo '<td>' . esc_html( $a['priority'] ) . '</td>';
echo '<td>' . esc_html( $a['title'] ) . '</td>';
echo '<td>' . esc_html( $a['updated_at'] ) . '</td>';
echo '</tr>';
if ( ! empty( $a['description_excerpt'] ) ) {
echo '<tr><td colspan="7" style="background:#f8f8f8;font-size:11.5px;color:#444;padding:6px 12px;"><em>excerpt:</em> ' . esc_html( substr( $a['description_excerpt'], 0, 400 ) ) . ( strlen( $a['description_excerpt'] ) > 400 ? '...' : '' ) . '</td></tr>';
}
}
echo '</tbody></table>';
}
}
echo '</div>';
}
}