diff --git a/hubzilla/addon/scn01/config.json.template b/hubzilla/addon/scn01/config.json.template new file mode 100644 index 0000000..4964c98 --- /dev/null +++ b/hubzilla/addon/scn01/config.json.template @@ -0,0 +1,5 @@ +{ + "_note": "Copy to config.json. Do not commit config.json — it contains secrets and installation-specific values. Access-state (associations/groups) is NOT duplicated here — scn01 reads addon/vs01/config.json as the single source of truth for that.", + "receiver_url": "REPLACE — orchestrator receiver endpoint, e.g. https://orchestrator1.internal.diagnostics.kane-il.us/receive-scn", + "node_token": "REPLACE — shared secret for node-to-orchestrator authentication" +} diff --git a/hubzilla/addon/scn01/scenarios.json b/hubzilla/addon/scn01/scenarios.json new file mode 100644 index 0000000..3eec99d --- /dev/null +++ b/hubzilla/addon/scn01/scenarios.json @@ -0,0 +1,30 @@ +{ + "_note": "Sanitized scenario library for scn01. Each entry is an operator/participant-sanitized account, tagged with a single open-vocabulary category. Edited by operator/expert participants over time. Used to populate the browse carousel on /scn01/{association_slug}.", + "scenarios": [ + { + "id": "scn-001", + "category": "Porch Pirates", + "text": "The HOA has not done anything about packages being stolen from porches. Multiple residents have reported missing deliveries, but no notice, camera policy, or response has come from the board." + }, + { + "id": "scn-002", + "category": "Noise Complaint", + "text": "The same residents across several units have been playing loud music late at night, seemingly on purpose. Complaints have not resulted in any visible action." + }, + { + "id": "scn-003", + "category": "Towing", + "text": "A guest's car was towed overnight from a space marked for guest parking. No warning was posted and no notice was given before the tow." + }, + { + "id": "scn-004", + "category": "Excessive Maintenance Charges", + "text": "The lawn is being mowed three times a week, which feels excessive. The frequency was not discussed with homeowners and the cost implications are unclear." + }, + { + "id": "scn-005", + "category": "Surprise Charges", + "text": "A one-time charge appeared on the account for removing a tree that had been dead for years. There was no advance notice that this charge was coming or why it was billed now." + } + ] +} diff --git a/hubzilla/addon/scn01/scn01.php b/hubzilla/addon/scn01/scn01.php index b89e764..300ab51 100644 --- a/hubzilla/addon/scn01/scn01.php +++ b/hubzilla/addon/scn01/scn01.php @@ -2,14 +2,17 @@ /** * Name: SCN01 Scenarios - * Description: Public civic diagnostic — browse and submit diagnostic scenarios. - * Version: 0.2.0 + * Description: Public civic diagnostic — browse and submit diagnostic scenarios in plain language. + * Version: 0.3.0 * MinVersion: 11.0 * MaxVersion: 12.0 */ use Zotlabs\Extend\Widget; +require_once 'addon/scn01/scn01_renderer.php'; +require_once 'addon/scn01/scn01_spool.php'; + function scn01_module() {} function scn01_load() { @@ -101,7 +104,7 @@ function scn01_access_state($association_slug = '') { intval($prof_gid), dbesc($observer) ); - if ($r) return 'participant'; + if ($r) return 'professional'; } return 'public'; @@ -127,7 +130,7 @@ function scn01_access_wall($association_slug = '') { } // --------------------------------------------------------------------------- -// CONTENT +// CONTENT ROUTER // --------------------------------------------------------------------------- function scn01_content() { @@ -140,37 +143,40 @@ function scn01_content() { $association_slug = argv(1) ?? ''; + // Index — no association selected + if (!$association_slug) { + return scn01_render_index(); + } + + $raw = @file_get_contents('addon/vs01/config.json'); + $cfg = $raw ? json_decode($raw, true) : []; + if (json_last_error() !== JSON_ERROR_NONE || !isset($cfg['associations'][$association_slug])) { + return scn01_render_not_found(); + } + $access = scn01_access_state($association_slug); - // scn01 is public — access wall only gates submission, not reading + // POST — submission handler if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($access === 'public') { return scn01_access_wall($association_slug); } - // TODO: handle POST submission - return scn01_access_wall($association_slug); + if (!scn01_verify_csrf()) { + return '
Invalid form token. Please reload and try again.
'; + } + return scn01_handle_post($association_slug, $access); } - return scn01_render_main($access); + // Association landing — carousel + submission form + return scn01_render_landing($association_slug, $access); } // --------------------------------------------------------------------------- -// RENDER +// NOT FOUND // --------------------------------------------------------------------------- -function scn01_render_main($access) { - $out = '
'; - $out .= '
'; - $out .= '

Scenarios

'; - $out .= '

Browse diagnostic scenarios. When you find one close to your situation, submit your account in your own words.

'; - $out .= '
'; - - // TODO: render scenarios - - $out .= '
Content forthcoming.
'; - $out .= '
'; - - return $out; +function scn01_render_not_found() { + return '
Association not found.
'; } // --------------------------------------------------------------------------- diff --git a/hubzilla/addon/scn01/scn01_renderer.php b/hubzilla/addon/scn01/scn01_renderer.php new file mode 100644 index 0000000..821e85d --- /dev/null +++ b/hubzilla/addon/scn01/scn01_renderer.php @@ -0,0 +1,117 @@ +'; + $out .= '
'; + $out .= '

Scenarios

'; + $out .= '

Browse examples of situations other homeowners have experienced. When something looks familiar, select it and describe your own situation in your own words.

'; + $out .= '
'; + + $raw = @file_get_contents('addon/vs01/config.json'); + $cfg = $raw ? json_decode($raw, true) : []; + $associations = $cfg['associations'] ?? []; + + if (!empty($associations)) { + $out .= ''; + } else { + $out .= '
No associations registered.
'; + } + + $out .= ''; + return $out; +} + +// --------------------------------------------------------------------------- +// RENDER — ASSOCIATION LANDING (carousel + form) +// --------------------------------------------------------------------------- + +function scn01_render_landing($association_slug, $access) { + $raw = @file_get_contents('addon/vs01/config.json'); + $cfg = $raw ? json_decode($raw, true) : []; + $assoc = $cfg['associations'][$association_slug] ?? []; + $name = scn01_h($assoc['name'] ?? $association_slug); + + $scenarios = scn01_load_scenarios(); + + $out = '
'; + $out .= '
'; + $out .= '

' . $name . '

'; + $out .= '

Browse the scenarios below. Select up to 5 that feel similar to your situation, then describe what happened in your own words.

'; + $out .= '
'; + + // Data island — consumed entirely by scn01.js, no PHP logic beyond this. + $out .= ''; + + // Pinned strip — populated client-side + $out .= '
'; + + // Carousel — populated client-side + $out .= ''; + + if ($access === 'public') { + $out .= '
'; + $out .= 'Scenarios are public. '; + $out .= 'Complete the SASE process '; + $out .= 'to submit your own account for this association.'; + $out .= '
'; + $out .= '
'; + return $out; + } + + $form_url = z_root() . '/scn01/' . scn01_h($association_slug); + + $out .= '
'; + $out .= scn01_csrf_token(); + $out .= '
'; + + $out .= '
'; + $out .= ''; + $out .= ''; + $out .= '
'; + + $out .= '
'; + $out .= 'Once submitted, this record cannot be edited. If you want to add more later, you will need to submit a new record.'; + $out .= '
'; + + $out .= '
'; + $out .= ''; + $out .= '
'; + $out .= '
'; + + $out .= ''; + return $out; +} diff --git a/hubzilla/addon/scn01/scn01_spool.php b/hubzilla/addon/scn01/scn01_spool.php new file mode 100644 index 0000000..873ff09 --- /dev/null +++ b/hubzilla/addon/scn01/scn01_spool.php @@ -0,0 +1,150 @@ +Please describe your situation before submitting.'; + $out .= scn01_render_landing($association_slug, $access); + return $out; + } + + $cfg_raw = @file_get_contents('addon/vs01/config.json'); + $cfg = $cfg_raw ? json_decode($cfg_raw, true) : []; + $assoc = $cfg['associations'][$association_slug] ?? []; + + $envelope = scn01_build_spool_envelope( + $narrative, + $pinned, + $association_slug, + $assoc['channel_id'] ?? '', + $access + ); + + $result = scn01_post_to_orchestrator($envelope); + + if ($result === true) { + $assoc_name = scn01_h($assoc['name'] ?? $association_slug); + return '
+
+ Your account for ' . $assoc_name . ' has been submitted. Records cannot be edited — + if you want to add more later, submit a new record. + + Return to ' . $assoc_name . ' + +
+
'; + } + + $out = '
+ Submission failed. Please try again or contact the operator. +
' . scn01_h($result) . ' +
'; + $out .= scn01_render_landing($association_slug, $access); + return $out; +} + +// --------------------------------------------------------------------------- +// SPOOL ENVELOPE +// --------------------------------------------------------------------------- + +function scn01_build_spool_envelope($narrative, $pinned_scenario_ids, $association_slug, $channel_id, $standing) { + return [ + 'addon' => 'scn01', + 'contract_version' => '1.0', + 'association_slug' => $association_slug, + 'association_channel_id' => (string) $channel_id, + 'submitted_at' => date('c'), + 'standing' => $standing, + 'pinned_scenario_ids' => $pinned_scenario_ids, + 'narrative' => $narrative, + ]; +} + +// --------------------------------------------------------------------------- +// ORCHESTRATOR POST +// --------------------------------------------------------------------------- + +function scn01_post_to_orchestrator($envelope) { + $config = scn01_load_config(); + $receiver_url = $config['receiver_url'] ?? ''; + $node_token = $config['node_token'] ?? ''; + + if (!$receiver_url) { + logger('scn01_spool: receiver_url not configured'); + return 'Orchestrator receiver URL not configured.'; + } + + $payload = json_encode($envelope); + if ($payload === false) { + logger('scn01_spool: failed to encode envelope'); + return 'Failed to encode submission.'; + } + + $ch = curl_init($receiver_url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'X-Node-Token: ' . $node_token, + ], + ]); + + $response = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curl_error = curl_error($ch); + curl_close($ch); + + if ($curl_error) { + logger('scn01_spool: curl error: ' . $curl_error); + return 'Network error: ' . $curl_error; + } + + if ($http_code === 200 || $http_code === 201) { + return true; + } + + logger('scn01_spool: orchestrator returned HTTP ' . $http_code . ': ' . $response); + return 'Orchestrator error (HTTP ' . $http_code . ').'; +} + +// --------------------------------------------------------------------------- +// CONFIG +// --------------------------------------------------------------------------- + +function scn01_load_config() { + $path = 'addon/scn01/config.json'; + $raw = @file_get_contents($path); + if ($raw === false) return []; + $data = json_decode($raw, true); + return (json_last_error() === JSON_ERROR_NONE) ? $data : []; +}