diff --git a/hubzilla/addon/assoc_profile/assoc_fields.json.template b/hubzilla/addon/assoc_profile/assoc_fields.json.template new file mode 100644 index 0000000..72c0944 --- /dev/null +++ b/hubzilla/addon/assoc_profile/assoc_fields.json.template @@ -0,0 +1,269 @@ +{ + "_note": "Copy to assoc_fields.json on the host. Do not commit assoc_fields.json. This file is the canonical field definition for all association registry entries. Adding a field here and saving via the management interface will backfill all existing associations with the default value.", + "_version": "1.0", + "groups": [ + "Identity", + "Physical Structure", + "Governance and Management", + "Legal and Compliance", + "Record" + ], + "fields": [ + { + "nickname": "assoc_legal_name", + "label": "Legal Name", + "type": "text", + "group": "Identity", + "default": "", + "required": true, + "help": "Full legal name as recorded with Illinois Secretary of State" + }, + { + "nickname": "assoc_type", + "label": "Association Type", + "type": "select", + "group": "Identity", + "default": "UNK", + "options": ["Condominium", "Master", "CIC", "Unincorporated", "UNK", "Disputed"], + "required": true, + "help": "VS-01 verified value" + }, + { + "nickname": "assoc_statute", + "label": "Governing Statute", + "type": "text", + "group": "Identity", + "default": "", + "required": false, + "help": "e.g. 765 ILCS 605 — populated after VS-01 verification" + }, + { + "nickname": "assoc_street", + "label": "Street Address", + "type": "text", + "group": "Identity", + "default": "", + "required": false, + "help": "Physical address of the property" + }, + { + "nickname": "assoc_city", + "label": "City", + "type": "text", + "group": "Identity", + "default": "", + "required": false, + "help": "" + }, + { + "nickname": "assoc_county", + "label": "County", + "type": "text", + "group": "Identity", + "default": "", + "required": false, + "help": "e.g. Cook County" + }, + { + "nickname": "assoc_state", + "label": "State", + "type": "text", + "group": "Identity", + "default": "IL", + "required": false, + "help": "" + }, + { + "nickname": "assoc_zip", + "label": "ZIP Code", + "type": "text", + "group": "Identity", + "default": "", + "required": false, + "help": "" + }, + { + "nickname": "assoc_placekey", + "label": "Placekey", + "type": "text", + "group": "Identity", + "default": "", + "required": false, + "help": "Permanent geographic anchor — e.g. 247-222@5sb-8bs-y35" + }, + { + "nickname": "assoc_website", + "label": "Association Website", + "type": "text", + "group": "Identity", + "default": "", + "required": false, + "help": "URL of association-managed site if any" + }, + { + "nickname": "assoc_buildings", + "label": "Number of Buildings", + "type": "text", + "group": "Physical Structure", + "default": "UNK", + "required": false, + "help": "Integer or UNK — VS-02" + }, + { + "nickname": "assoc_units", + "label": "Number of Units", + "type": "text", + "group": "Physical Structure", + "default": "UNK", + "required": false, + "help": "Integer or UNK — VS-02" + }, + { + "nickname": "assoc_year_built", + "label": "Year Built", + "type": "text", + "group": "Physical Structure", + "default": "", + "required": false, + "help": "e.g. 1974" + }, + { + "nickname": "assoc_stories", + "label": "Stories Per Building", + "type": "text", + "group": "Physical Structure", + "default": "", + "required": false, + "help": "e.g. 3" + }, + { + "nickname": "assoc_mgmt_company", + "label": "Management Company", + "type": "text", + "group": "Governance and Management", + "default": "", + "required": false, + "help": "" + }, + { + "nickname": "assoc_mgmt_contact", + "label": "Management Contact", + "type": "text", + "group": "Governance and Management", + "default": "", + "required": false, + "help": "Name and email of primary contact" + }, + { + "nickname": "assoc_mgmt_certified", + "label": "Manager IDFPR Certified", + "type": "select", + "group": "Governance and Management", + "default": "UNK", + "options": ["Yes", "No", "UNK"], + "required": false, + "help": "VS-03" + }, + { + "nickname": "assoc_cai_listing", + "label": "CAI Directory Listing", + "type": "text", + "group": "Governance and Management", + "default": "", + "required": false, + "help": "How the association or its management company appears in CAI — member, vendor, AAMC, etc." + }, + { + "nickname": "assoc_bbb_standing", + "label": "BBB Standing", + "type": "text", + "group": "Governance and Management", + "default": "", + "required": false, + "help": "Better Business Bureau rating and status for the management company" + }, + { + "nickname": "assoc_idfpr_complaint", + "label": "IDFPR Complaints", + "type": "text", + "group": "Governance and Management", + "default": "", + "required": false, + "help": "Any complaints on record against the manager at IDFPR" + }, + { + "nickname": "assoc_sos_id", + "label": "SOS Entity File Number", + "type": "text", + "group": "Legal and Compliance", + "default": "", + "required": false, + "help": "Illinois Secretary of State entity file number — VS-08" + }, + { + "nickname": "assoc_sos_standing", + "label": "Corporate Standing", + "type": "select", + "group": "Legal and Compliance", + "default": "UNK", + "options": ["Active", "Dissolved", "UNK"], + "required": false, + "help": "VS-08" + }, + { + "nickname": "assoc_declaration_recorded", + "label": "Declaration Recorded", + "type": "select", + "group": "Legal and Compliance", + "default": "UNK", + "options": ["Yes", "No", "UNK"], + "required": false, + "help": "VS-10" + }, + { + "nickname": "assoc_declaration_instrument", + "label": "Declaration Instrument No.", + "type": "text", + "group": "Legal and Compliance", + "default": "", + "required": false, + "help": "County Recorder document number — VS-10" + }, + { + "nickname": "assoc_liens_active", + "label": "Active Liens", + "type": "text", + "group": "Legal and Compliance", + "default": "", + "required": false, + "help": "Count or description of active liens recorded against units or the association" + }, + { + "nickname": "assoc_litigation_active", + "label": "Active Litigation", + "type": "text", + "group": "Legal and Compliance", + "default": "", + "required": false, + "help": "Known active cases from public court record" + }, + { + "nickname": "assoc_registered", + "label": "Registered Date", + "type": "readonly", + "group": "Record", + "default": "", + "required": false, + "help": "ISO date when this association was added to the registry — set automatically" + }, + { + "nickname": "assoc_updated", + "label": "Last Updated", + "type": "readonly", + "group": "Record", + "default": "", + "required": false, + "help": "ISO date of most recent update — set automatically" + } + ] +} diff --git a/hubzilla/addon/assoc_profile/assoc_profile.php b/hubzilla/addon/assoc_profile/assoc_profile.php index 175d698..7784179 100644 --- a/hubzilla/addon/assoc_profile/assoc_profile.php +++ b/hubzilla/addon/assoc_profile/assoc_profile.php @@ -3,147 +3,356 @@ /** * Name: Association Profile * Description: Structured diagnostic identity fields for HOA association channels. - * Version: 0.1.0 + * Version: 0.2.0 * MinVersion: 11.0 * MaxVersion: 12.0 */ function assoc_profile_module() {} +require_once 'addon/assoc_profile/assoc_profile_manage.php'; + function assoc_profile_load() { - register_hook('profile_edit', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_edit_hook'); register_hook('profile_advanced', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_view_hook'); + register_hook('profile_edit', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_edit_hook'); } function assoc_profile_unload() { - unregister_hook('profile_edit', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_edit_hook'); unregister_hook('profile_advanced', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_view_hook'); + unregister_hook('profile_edit', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_edit_hook'); } // ---------------------------------------------------------------------------- // HELPERS // ---------------------------------------------------------------------------- -function assoc_h($value) { - return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +function assoc_h($v) { + return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); } -function assoc_slug_from_address($channel_address) { - // channel_address is "slug@node" — extract the slug - $parts = explode('@', $channel_address); - return $parts[0] ?? ''; +function assoc_slug_from_address($addr) { + return explode('@', $addr)[0] ?? ''; +} + +function assoc_is_operator() { + if (!local_channel()) return false; + $channel = App::get_channel(); + return local_channel() === intval($channel['channel_id']); } // ---------------------------------------------------------------------------- -// REGISTRY I/O +// FILE I/O // ---------------------------------------------------------------------------- function assoc_load_config() { - $path = 'addon/assoc_profile/config.json'; - $raw = @file_get_contents($path); - if ($raw === false) return []; - $data = json_decode($raw, true); - return (json_last_error() === JSON_ERROR_NONE) ? $data : []; + $raw = @file_get_contents('addon/assoc_profile/config.json'); + if (!$raw) return []; + $d = json_decode($raw, true); + return json_last_error() === JSON_ERROR_NONE ? $d : []; +} + +function assoc_registry_path() { + $c = assoc_load_config(); + return $c['registry_file'] ?? 'addon/assoc_profile/assoc_registry.json'; +} + +function assoc_fields_path() { + $c = assoc_load_config(); + return $c['fields_file'] ?? 'addon/assoc_profile/assoc_fields.json'; } function assoc_load_registry() { - $config = assoc_load_config(); - $path = $config['registry_file'] - ?? 'addon/assoc_profile/assoc_registry.json'; - $raw = @file_get_contents($path); - if ($raw === false) { - logger('assoc_profile: registry file not found: ' . $path); - return []; - } - $data = json_decode($raw, true); - if (json_last_error() !== JSON_ERROR_NONE) { - logger('assoc_profile: registry file malformed: ' . $path); - return []; - } - // Strip meta keys - foreach (['_note','_version','_slug_format','_select_values'] as $k) { - unset($data[$k]); - } - return $data; + $raw = @file_get_contents(assoc_registry_path()); + if (!$raw) { logger('assoc_profile: registry not found'); return []; } + $d = json_decode($raw, true); + if (json_last_error() !== JSON_ERROR_NONE) { logger('assoc_profile: registry malformed'); return []; } + foreach (['_note','_version','_slug_format','_select_values'] as $k) unset($d[$k]); + return $d; } function assoc_write_registry($data) { - $config = assoc_load_config(); - $path = $config['registry_file'] - ?? 'addon/assoc_profile/assoc_registry.json'; - $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - if ($json === false) { - logger('assoc_profile: failed to encode registry'); - return false; - } - $result = file_put_contents($path, $json, LOCK_EX); - if ($result === false) { - logger('assoc_profile: failed to write registry: ' . $path); - return false; + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + if (!$json) { logger('assoc_profile: encode failed'); return false; } + if (file_put_contents(assoc_registry_path(), $json, LOCK_EX) === false) { + logger('assoc_profile: write failed'); return false; } return true; } +function assoc_load_fields() { + $raw = @file_get_contents(assoc_fields_path()); + if (!$raw) { logger('assoc_profile: fields file not found'); return ['groups'=>[],'fields'=>[]]; } + $d = json_decode($raw, true); + if (json_last_error() !== JSON_ERROR_NONE) { logger('assoc_profile: fields file malformed'); return ['groups'=>[],'fields'=>[]]; } + return $d; +} + +function assoc_write_fields($data) { + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + if (!$json) return false; + return file_put_contents(assoc_fields_path(), $json, LOCK_EX) !== false; +} + function assoc_get($slug) { + $r = assoc_load_registry(); + return $r[$slug] ?? null; +} + +// ---------------------------------------------------------------------------- +// FIELD HELPERS +// ---------------------------------------------------------------------------- + +function assoc_field_map() { + $def = assoc_load_fields(); + $map = []; + foreach ($def['fields'] ?? [] as $f) { + $map[$f['nickname']] = $f; + } + return $map; +} + +function assoc_blank_entry() { + $map = assoc_field_map(); + $entry = []; + foreach ($map as $nick => $f) { + $entry[$nick] = $f['default'] ?? ''; + } + return $entry; +} + +function assoc_backfill_all($new_field_def) { $registry = assoc_load_registry(); - return $registry[$slug] ?? null; + $nick = $new_field_def['nickname']; + $default = $new_field_def['default'] ?? ''; + foreach ($registry as $slug => &$entry) { + if (!isset($entry[$nick])) { + $entry[$nick] = $default; + } + } + unset($entry); + return $registry; +} + +function assoc_remove_field_from_all($nickname) { + $registry = assoc_load_registry(); + foreach ($registry as $slug => &$entry) { + unset($entry[$nickname]); + } + unset($entry); + return $registry; } // ---------------------------------------------------------------------------- -// VALIDATION +// CONTENT ROUTER // ---------------------------------------------------------------------------- -function assoc_allowed_values() { - return [ - 'assoc_type' => ['Condominium','Master','CIC','Unincorporated','UNK','Disputed'], - 'assoc_mgmt_certified' => ['Yes','No','UNK'], - 'assoc_sos_standing' => ['Active','Dissolved','UNK'], - 'assoc_declaration_recorded' => ['Yes','No','UNK'], - ]; -} - -function assoc_validate_select($field, $value) { - $allowed = assoc_allowed_values(); - if (!isset($allowed[$field])) return true; - return in_array($value, $allowed[$field], true); -} - -function assoc_field_names() { - return [ - 'assoc_legal_name', 'assoc_type', 'assoc_statute', - 'assoc_street', 'assoc_city', 'assoc_county', 'assoc_state', - 'assoc_zip', 'assoc_placekey', 'assoc_website', - 'assoc_buildings', 'assoc_units', 'assoc_year_built', 'assoc_stories', - 'assoc_mgmt_company', 'assoc_mgmt_contact', 'assoc_mgmt_certified', - 'assoc_cai_listing', - 'assoc_sos_id', 'assoc_sos_standing', - 'assoc_declaration_recorded', 'assoc_declaration_instrument', - 'assoc_registered', 'assoc_updated', - ]; -} - -// ---------------------------------------------------------------------------- -// PROFILE EDIT HOOK -// ---------------------------------------------------------------------------- - -function assoc_profile_edit_hook(&$b) { - if (!local_channel()) return; - - $channel = App::get_channel(); - $slug = assoc_slug_from_address($channel['channel_address'] ?? ''); - if (!$slug) return; - - $entry = assoc_get($slug); - if (!$entry) return; - - // Only channel owner may edit - if (local_channel() !== intval($channel['channel_id'])) return; - +function assoc_profile_content() { if (function_exists('head_add_css')) { head_add_css('/addon/assoc_profile/view/css/assoc_profile.css'); } - $b['html'] .= assoc_render_edit_form($slug, $entry); + $action = argv(1) ?? ''; + + if ($action !== 'manage') { + return ''; + } + + if (!assoc_is_operator()) { + return '
Operator access required.
'; + } + + $sub = argv(2) ?? ''; + $slug = argv(3) ?? ''; + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + return assoc_handle_post(); + } + + // Manage index + if (!$sub) return assoc_render_manage_index(); + + // Association actions + if ($sub === 'assoc') { + if (!$slug) return assoc_render_add_association_form(); + return assoc_render_edit_association_form($slug); + } + + // Field actions + if ($sub === 'fields') return assoc_render_fields_form(); + + return '
Unknown management action.
'; +} + +// ---------------------------------------------------------------------------- +// POST HANDLER +// ---------------------------------------------------------------------------- + +function assoc_handle_post() { + if (!assoc_verify_csrf()) { + return '
Invalid form token.
'; + } + + $action = $_POST['assoc_action'] ?? ''; + + switch ($action) { + case 'save_association': return assoc_save_association(); + case 'add_association': return assoc_add_association(); + case 'save_fields': return assoc_save_fields(); + case 'add_field': return assoc_add_field(); + case 'remove_field': return assoc_remove_field(); + default: + return '
Unknown action.
'; + } +} + +// ---------------------------------------------------------------------------- +// SAVE ASSOCIATION +// ---------------------------------------------------------------------------- + +function assoc_save_association() { + $slug = substr(strip_tags($_POST['assoc_slug'] ?? ''), 0, 128); + if (!$slug) return '
Missing slug.
'; + + $registry = assoc_load_registry(); + if (!isset($registry[$slug])) { + return '
Association not found.
'; + } + + $field_map = assoc_field_map(); + foreach ($field_map as $nick => $fdef) { + if (($fdef['type'] ?? '') === 'readonly') continue; + if (!isset($_POST[$nick])) continue; + $val = substr(strip_tags((string)$_POST[$nick]), 0, 1024); + if (isset($fdef['options']) && !in_array($val, $fdef['options'], true)) continue; + $registry[$slug][$nick] = $val; + } + $registry[$slug]['assoc_updated'] = date('Y-m-d'); + + if (assoc_write_registry($registry)) { + goaway(z_root() . '/assoc_profile/manage/assoc/' . $slug); + } + return '
Save failed. Check server logs.
'; +} + +// ---------------------------------------------------------------------------- +// ADD ASSOCIATION +// ---------------------------------------------------------------------------- + +function assoc_add_association() { + $slug = strtolower(trim($_POST['new_slug'] ?? '')); + $slug = preg_replace('/[^a-z0-9\-]/', '', $slug); + if (!$slug) return '
Invalid slug.
'; + + $registry = assoc_load_registry(); + if (isset($registry[$slug])) { + return '
Slug already exists: ' . assoc_h($slug) . '
'; + } + + $entry = assoc_blank_entry(); + $entry['assoc_registered'] = date('Y-m-d'); + $entry['assoc_updated'] = date('Y-m-d'); + $registry[$slug] = $entry; + + if (assoc_write_registry($registry)) { + goaway(z_root() . '/assoc_profile/manage/assoc/' . $slug); + } + return '
Failed to create association.
'; +} + +// ---------------------------------------------------------------------------- +// FIELD MANAGEMENT +// ---------------------------------------------------------------------------- + +function assoc_save_fields() { + // Save label and group changes to existing fields + $def = assoc_load_fields(); + $fields = &$def['fields']; + + foreach ($fields as &$f) { + $nick = $f['nickname']; + if (isset($_POST['label_' . $nick])) { + $f['label'] = substr(strip_tags($_POST['label_' . $nick]), 0, 128); + } + if (isset($_POST['group_' . $nick])) { + $f['group'] = substr(strip_tags($_POST['group_' . $nick]), 0, 64); + } + if (isset($_POST['help_' . $nick])) { + $f['help'] = substr(strip_tags($_POST['help_' . $nick]), 0, 256); + } + } + unset($f); + + // Save group order + if (!empty($_POST['groups'])) { + $groups = array_map(function($g) { return substr(strip_tags($g), 0, 64); }, + explode("\n", $_POST['groups'])); + $def['groups'] = array_values(array_filter(array_map('trim', $groups))); + } + + if (assoc_write_fields($def)) { + goaway(z_root() . '/assoc_profile/manage/fields'); + } + return '
Save failed.
'; +} + +function assoc_add_field() { + $nick = strtolower(preg_replace('/[^a-z0-9_]/', '', $_POST['new_nickname'] ?? '')); + $label = substr(strip_tags($_POST['new_label'] ?? ''), 0, 128); + $type = in_array($_POST['new_type'] ?? '', ['text','select','readonly']) ? $_POST['new_type'] : 'text'; + $group = substr(strip_tags($_POST['new_group'] ?? ''), 0, 64); + $default = substr(strip_tags($_POST['new_default'] ?? ''), 0, 128); + $help = substr(strip_tags($_POST['new_help'] ?? ''), 0, 256); + + if (!$nick || !$label) { + return '
Nickname and label are required.
'; + } + + $def = assoc_load_fields(); + foreach ($def['fields'] as $f) { + if ($f['nickname'] === $nick) { + return '
Field already exists: ' . assoc_h($nick) . '
'; + } + } + + $new_field = [ + 'nickname' => $nick, + 'label' => $label, + 'type' => $type, + 'group' => $group, + 'default' => $default, + 'required' => false, + 'help' => $help, + ]; + + if ($type === 'select' && !empty($_POST['new_options'])) { + $opts = array_map('trim', explode("\n", $_POST['new_options'])); + $new_field['options'] = array_values(array_filter($opts)); + } + + $def['fields'][] = $new_field; + assoc_write_fields($def); + + // Backfill all existing associations + $registry = assoc_backfill_all($new_field); + assoc_write_registry($registry); + + goaway(z_root() . '/assoc_profile/manage/fields'); +} + +function assoc_remove_field() { + $nick = preg_replace('/[^a-z0-9_]/', '', $_POST['remove_nickname'] ?? ''); + if (!$nick) return '
Missing nickname.
'; + + $def = assoc_load_fields(); + $def['fields'] = array_values(array_filter($def['fields'], function($f) use ($nick) { + return $f['nickname'] !== $nick; + })); + assoc_write_fields($def); + + $registry = assoc_remove_field_from_all($nick); + assoc_write_registry($registry); + + goaway(z_root() . '/assoc_profile/manage/fields'); } // ---------------------------------------------------------------------------- @@ -153,215 +362,70 @@ function assoc_profile_edit_hook(&$b) { function assoc_profile_view_hook(&$o) { $uid = intval(App::$profile['profile_uid'] ?? 0); if (!$uid) return; - - $r = q("SELECT channel_address FROM channel WHERE channel_id = %d LIMIT 1", - intval($uid)); + $r = q("SELECT channel_address FROM channel WHERE channel_id = %d LIMIT 1", intval($uid)); if (!$r) return; - $slug = assoc_slug_from_address($r[0]['channel_address']); $entry = assoc_get($slug); if (!$entry) return; - - if (function_exists('head_add_css')) { - head_add_css('/addon/assoc_profile/view/css/assoc_profile.css'); - } - + if (function_exists('head_add_css')) head_add_css('/addon/assoc_profile/view/css/assoc_profile.css'); $o .= assoc_render_view($entry); } // ---------------------------------------------------------------------------- -// RENDER — VIEW +// PROFILE EDIT HOOK +// ---------------------------------------------------------------------------- + +function assoc_profile_edit_hook(&$b) { + if (!assoc_is_operator()) return; + $channel = App::get_channel(); + $slug = assoc_slug_from_address($channel['channel_address'] ?? ''); + $entry = assoc_get($slug); + if (!$entry) return; + if (function_exists('head_add_css')) head_add_css('/addon/assoc_profile/view/css/assoc_profile.css'); + $b['html'] .= ''; +} + +// ---------------------------------------------------------------------------- +// RENDER — PROFILE VIEW // ---------------------------------------------------------------------------- function assoc_render_view($entry) { - $labels = assoc_field_labels(); - $groups = assoc_field_groups(); + $def = assoc_load_fields(); + $groups = $def['groups'] ?? []; + $fields = $def['fields'] ?? []; + + $by_group = []; + foreach ($fields as $f) { + $by_group[$f['group']][] = $f; + } $out = '
'; $out .= '

Association Diagnostic Profile

'; - foreach ($groups as $group_label => $fields) { + foreach ($groups as $group) { + $group_fields = $by_group[$group] ?? []; + if (empty($group_fields)) continue; $out .= '
'; - $out .= '
' . assoc_h($group_label) . '
'; - foreach ($fields as $field) { - $val = $entry[$field] ?? ''; + $out .= '
' . assoc_h($group) . '
'; + foreach ($group_fields as $f) { + $nick = $f['nickname']; + $val = $entry[$nick] ?? ''; if ($val === '') $val = '—'; - if (in_array($val, ['UNK'])) $val = 'Unknown — not yet verified'; + if ($val === 'UNK') $val = 'Unknown — not yet verified'; $out .= '
'; - $out .= '' . assoc_h($labels[$field] ?? $field) . ': '; + $out .= '' . assoc_h($f['label'] ?? $nick) . ': '; $out .= '' . assoc_h($val) . ''; $out .= '
'; } $out .= '
'; } - $out .= '
'; return $out; } -// ---------------------------------------------------------------------------- -// RENDER — EDIT FORM -// ---------------------------------------------------------------------------- - -function assoc_render_edit_form($slug, $entry) { - $labels = assoc_field_labels(); - $groups = assoc_field_groups(); - $allowed = assoc_allowed_values(); - - $out = '
'; - $out .= '

Association Diagnostic Profile

'; - $out .= '
'; - $out .= ''; - $out .= assoc_csrf_token(); - - foreach ($groups as $group_label => $fields) { - $out .= '
'; - $out .= '
' . assoc_h($group_label) . '
'; - foreach ($fields as $field) { - if (in_array($field, ['assoc_registered', 'assoc_updated'])) continue; - $val = $entry[$field] ?? ''; - $label = $labels[$field] ?? $field; - if (isset($allowed[$field])) { - $out .= assoc_render_select($field, $label, $val, $allowed[$field]); - } else { - $out .= assoc_render_text($field, $label, $val); - } - } - $out .= '
'; - } - - $out .= '
'; - $out .= ''; - $out .= '
'; - $out .= '
'; - return $out; -} - -function assoc_render_text($field, $label, $value) { - $id = assoc_h($field); - return '
- - -
'; -} - -function assoc_render_select($field, $label, $value, $options) { - $id = assoc_h($field); - $out = '
'; - $out .= ''; - $out .= '
'; - return $out; -} - -// ---------------------------------------------------------------------------- -// SAVE HANDLER -// ---------------------------------------------------------------------------- - -function assoc_profile_content() { - if (argv(1) !== 'save') return ''; - if ($_SERVER['REQUEST_METHOD'] !== 'POST') return ''; - if (!local_channel()) return ''; - if (!assoc_verify_csrf()) { - return '
Invalid form token.
'; - } - - $slug = substr(strip_tags($_POST['assoc_slug'] ?? ''), 0, 128); - if (!$slug) return '
Missing association slug.
'; - - $registry = assoc_load_registry(); - if (!isset($registry[$slug])) { - return '
Association not found in registry.
'; - } - - // Verify caller is channel owner for this slug - $channel = App::get_channel(); - if (assoc_slug_from_address($channel['channel_address'] ?? '') !== $slug) { - return '
Not authorized.
'; - } - - $allowed = assoc_allowed_values(); - $fields = assoc_field_names(); - - foreach ($fields as $field) { - if (in_array($field, ['assoc_registered', 'assoc_updated'])) continue; - if (!isset($_POST[$field])) continue; - $val = substr(strip_tags((string) $_POST[$field]), 0, 512); - if (isset($allowed[$field]) && !assoc_validate_select($field, $val)) continue; - $registry[$slug][$field] = $val; - } - - $registry[$slug]['assoc_updated'] = date('Y-m-d'); - - if (assoc_write_registry($registry)) { - goaway(z_root() . '/profile/' . $slug); - } - - return '
Failed to save. Check server logs.
'; -} - -// ---------------------------------------------------------------------------- -// FIELD METADATA -// ---------------------------------------------------------------------------- - -function assoc_field_labels() { - return [ - 'assoc_legal_name' => 'Legal Name', - 'assoc_type' => 'Association Type', - 'assoc_statute' => 'Governing Statute', - 'assoc_street' => 'Street Address', - 'assoc_city' => 'City', - 'assoc_county' => 'County', - 'assoc_state' => 'State', - 'assoc_zip' => 'ZIP Code', - 'assoc_placekey' => 'Placekey', - 'assoc_website' => 'Association Website', - 'assoc_buildings' => 'Number of Buildings', - 'assoc_units' => 'Number of Units', - 'assoc_year_built' => 'Year Built', - 'assoc_stories' => 'Stories Per Building', - 'assoc_mgmt_company' => 'Management Company', - 'assoc_mgmt_contact' => 'Management Contact', - 'assoc_mgmt_certified' => 'Manager IDFPR Certified', - 'assoc_cai_listing' => 'CAI Directory Listing', - 'assoc_sos_id' => 'SOS Entity File Number', - 'assoc_sos_standing' => 'Corporate Standing', - 'assoc_declaration_recorded' => 'Declaration Recorded', - 'assoc_declaration_instrument' => 'Declaration Instrument No.', - 'assoc_registered' => 'Registered Date', - 'assoc_updated' => 'Last Updated', - ]; -} - -function assoc_field_groups() { - return [ - 'Identity' => [ - 'assoc_legal_name','assoc_type','assoc_statute', - 'assoc_street','assoc_city','assoc_county', - 'assoc_state','assoc_zip','assoc_placekey','assoc_website', - ], - 'Physical Structure' => [ - 'assoc_buildings','assoc_units','assoc_year_built','assoc_stories', - ], - 'Governance and Management' => [ - 'assoc_mgmt_company','assoc_mgmt_contact','assoc_mgmt_certified', - 'assoc_cai_listing', - 'assoc_sos_id','assoc_sos_standing', - 'assoc_declaration_recorded','assoc_declaration_instrument', - ], - 'Record' => [ - 'assoc_registered','assoc_updated', - ], - ]; -} - // ---------------------------------------------------------------------------- // CSRF // ---------------------------------------------------------------------------- @@ -370,8 +434,7 @@ function assoc_csrf_token() { if (empty($_SESSION['assoc_profile_csrf'])) { $_SESSION['assoc_profile_csrf'] = bin2hex(random_bytes(16)); } - return ''; + return ''; } function assoc_verify_csrf() { diff --git a/hubzilla/addon/assoc_profile/assoc_profile_manage.php b/hubzilla/addon/assoc_profile/assoc_profile_manage.php new file mode 100644 index 0000000..af8cf19 --- /dev/null +++ b/hubzilla/addon/assoc_profile/assoc_profile_manage.php @@ -0,0 +1,198 @@ +'; + $out .= '

Association Profile — Management

'; + $out .= '
'; + $out .= 'Add Association'; + $out .= 'Manage Fields'; + $out .= '
'; + if (empty($registry)) { + $out .= '

No associations registered.

'; + } else { + $out .= ''; + $out .= ''; + foreach ($registry as $slug => $entry) { + $out .= ''; + $out .= ''; + $out .= ''; + $out .= ''; + $out .= ''; + $out .= ''; + $out .= ''; + $out .= ''; + } + $out .= '
SlugLegal NameTypeUnitsUpdated
' . assoc_h($slug) . '' . assoc_h($entry['assoc_legal_name'] ?? '—') . '' . assoc_h($entry['assoc_type'] ?? '—') . '' . assoc_h($entry['assoc_units'] ?? '—') . '' . assoc_h($entry['assoc_updated'] ?? '—') . 'Edit
'; + } + $out .= ''; + return $out; +} + +function assoc_render_add_association_form() { + $out = '
'; + $out .= '

Add Association

'; + $out .= '
'; + $out .= assoc_csrf_token(); + $out .= ''; + $out .= '
'; + $out .= ''; + $out .= ''; + $out .= '
'; + $out .= ' '; + $out .= 'Cancel'; + $out .= '
'; + return $out; +} + +function assoc_render_edit_association_form($slug) { + $registry = assoc_load_registry(); + if (!isset($registry[$slug])) { + return '
Association not found: ' . assoc_h($slug) . '
'; + } + $entry = $registry[$slug]; + $def = assoc_load_fields(); + $groups = $def['groups'] ?? []; + $fields = $def['fields'] ?? []; + $by_group = []; + foreach ($fields as $f) { + $by_group[$f['group']][] = $f; + } + + $out = '
'; + $out .= '

' . assoc_h($entry['assoc_legal_name'] ?? $slug) . '

'; + $out .= '

' . assoc_h($slug) . '

'; + $out .= '
'; + $out .= assoc_csrf_token(); + $out .= ''; + $out .= ''; + + foreach ($groups as $group) { + $group_fields = $by_group[$group] ?? []; + if (empty($group_fields)) continue; + $out .= '
' . assoc_h($group) . '
'; + foreach ($group_fields as $f) { + $nick = $f['nickname']; + $val = $entry[$nick] ?? ($f['default'] ?? ''); + $type = $f['type'] ?? 'text'; + $label = $f['label'] ?? $nick; + $help = $f['help'] ?? ''; + + if ($type === 'readonly') { + $out .= '
'; + $out .= ''; + $out .= '
' . assoc_h($val ?: '—') . '
'; + $out .= '
'; + continue; + } + + $out .= '
'; + $out .= ''; + if ($help) $out .= '' . assoc_h($help) . ''; + + if ($type === 'select' && !empty($f['options'])) { + $out .= ''; + } else { + $out .= ''; + } + $out .= '
'; + } + } + + $out .= '
'; + $out .= ' '; + $out .= 'Back'; + $out .= '
'; + return $out; +} + +function assoc_render_fields_form() { + $def = assoc_load_fields(); + $groups = $def['groups'] ?? []; + $fields = $def['fields'] ?? []; + + $out = '
'; + $out .= '

Manage Fields

'; + $out .= '

Changes to labels and groups take effect immediately. Adding a field backfills all existing associations with the default value. Removing a field deletes its data from all associations — this cannot be undone.

'; + + $out .= '
'; + $out .= assoc_csrf_token(); + $out .= ''; + $out .= ''; + $out .= ''; + foreach ($fields as $f) { + $nick = $f['nickname']; + $out .= ''; + $out .= ''; + $out .= ''; + $out .= ''; + $out .= ''; + $out .= ''; + $out .= ''; + } + $out .= '
NicknameLabelTypeGroupHelp
' . assoc_h($nick) . '' . assoc_h($f['type'] ?? 'text') . ''; + if (($f['type'] ?? '') !== 'readonly') { + $out .= ''; + } + $out .= '
'; + $out .= ''; + $out .= '
'; + + foreach ($fields as $f) { + if (($f['type'] ?? '') === 'readonly') continue; + $nick = $f['nickname']; + $out .= '
'; + $out .= assoc_csrf_token(); + $out .= ''; + $out .= ''; + $out .= '
'; + } + + $out .= '
Add New Field
'; + $out .= '
'; + $out .= assoc_csrf_token(); + $out .= ''; + $out .= '
'; + $out .= '
'; + $out .= '
'; + $out .= '
+
'; + $out .= '
'; + $out .= '
'; + $out .= '
'; + $out .= '
'; + $out .= '
'; + $out .= '
'; + $out .= '
'; + + $out .= '
Group Order
'; + $out .= '
'; + $out .= assoc_csrf_token(); + $out .= ''; + $out .= '
'; + $out .= '
'; + $out .= ''; + $out .= '
'; + + $out .= '
Back
'; + $out .= '
'; + return $out; +} diff --git a/hubzilla/addon/assoc_profile/config.json.template b/hubzilla/addon/assoc_profile/config.json.template index 9d400bb..5796b37 100644 --- a/hubzilla/addon/assoc_profile/config.json.template +++ b/hubzilla/addon/assoc_profile/config.json.template @@ -1,4 +1,5 @@ { "_note": "Copy to config.json. Do not commit config.json.", - "registry_file": "REPLACE — absolute path to assoc_registry.json on the host, e.g. /var/www/hubzilla/addon/assoc_profile/assoc_registry.json" + "registry_file": "REPLACE — absolute path to assoc_registry.json, e.g. /var/www/hubzilla/addon/assoc_profile/assoc_registry.json", + "fields_file": "REPLACE — absolute path to assoc_fields.json, e.g. /var/www/hubzilla/addon/assoc_profile/assoc_fields.json" }