feat(idaa): enforce mandatory Novi UUID linkage for member content

CRITICAL IDENTITY FIX:
Ensures all member-generated content (Meetings, Posts, Comments) is explicitly linked to the creator's Novi UUID via 'external_person_id' at the moment of creation.

Changes:
- Added 'external_person_id' to creation payloads in Recovery Meetings and BB Posts.
- Implemented 'identity scavenging' from localStorage in submit handlers to prevent race conditions where Svelte stores are briefly null.
- Refactored Post Comment edit component to robustly initialize and save creator identity.
- Added 'The Novi UUID Rule' to IDAA documentation to mandate this pattern for future development.
- Added Playwright test to verify creation linkage and fixed a version-mismatch bug in the test auth helper.

Note: Archives and Archive Content are excluded as they do not require member ownership.
This commit is contained in:
Scott Idem
2026-04-07 22:07:53 -04:00
parent ef45a0ca0f
commit f2765d6a5e
6 changed files with 135 additions and 13 deletions

View File

@@ -219,6 +219,25 @@ If you need a compact checklist for re-creating this flow in another integration
`novi_trusted_li` and `novi_admin_li` are managed in Aether site config (not in Novi directly).
## Identity Linkage: The Novi UUID Rule (Triple Linkage)
**CRITICAL ARCHITECTURAL STANDARD:**
All member-generated content in the IDAA module MUST be explicitly linked to the member's Novi UUID via the `external_person_id` field. This linkage is the primary mechanism for ownership, edit permissions, and auditing.
### 1. Mandatory at Creation
Linkage MUST happen at the moment of initial object creation (POST). Shell records created without an `external_person_id` are considered orphaned and may be inaccessible to the creator.
### 2. Triple Linkage Scope
The following objects require mandatory `external_person_id` linkage:
- **Recovery Meetings** (`ae_Event`)
- **Bulletin Board Posts** (`ae_Post`)
- **Post Comments** (`ae_PostComment`)
### 3. Implementation Patterns
- **Buttons:** Creation buttons (e.g., "Create New Meeting") must include `external_person_id: $idaa_loc.novi_uuid` in their initial `create_ae_obj` payload.
- **Edit Forms:** Edit components must provide robust fallbacks to `$idaa_loc.novi_uuid` for new or incomplete records, ensuring identity is captured even if the initial creation call was narrow.
- **Identity Sync:** Along with the UUID, `full_name` and `email` should also be synced from `$idaa_loc` to provide human-readable context in notifications and admin views.
### Permission Upgrade Rule
```
// RULE: Only UPGRADE to Novi-based permissions, NEVER downgrade.

View File

@@ -43,9 +43,13 @@ let prom_api__post_comment_obj: any = $state();
let post_comment_form = $state({
content: $idaa_slct.post_comment_obj?.content ?? '',
anonymous: $idaa_slct.post_comment_obj?.anonymous ?? false,
external_person_id: $idaa_slct.post_comment_obj?.external_person_id ?? '',
full_name: $idaa_slct.post_comment_obj?.full_name ?? '',
email: $idaa_slct.post_comment_obj?.email ?? '',
external_person_id:
$idaa_slct.post_comment_obj?.external_person_id ||
$idaa_loc.novi_uuid ||
'',
full_name:
$idaa_slct.post_comment_obj?.full_name || $idaa_loc.novi_full_name || '',
email: $idaa_slct.post_comment_obj?.email || $idaa_loc.novi_email || '',
enable: $idaa_slct.post_comment_obj?.enable ?? true,
hide: $idaa_slct.post_comment_obj?.hide ?? false,
priority: $idaa_slct.post_comment_obj?.priority ?? false,
@@ -94,10 +98,29 @@ async function handle_submit_form(event: any) {
// SVELTE 5: Map values from local form state to payload
post_comment_do['content'] = content_new_html;
post_comment_do['anonymous'] = post_comment_form.anonymous;
post_comment_do['external_person_id'] =
post_comment_form.external_person_id;
post_comment_do['full_name'] = post_comment_form.full_name;
post_comment_do['email'] = post_comment_form.email;
// Robust scavenging of identity from storage if form state is missing it
let novi_uuid = post_comment_form.external_person_id;
let novi_full_name = post_comment_form.full_name;
let novi_email = post_comment_form.email;
if (!novi_uuid && typeof localStorage !== 'undefined') {
try {
const ls_val = localStorage.getItem('ae_idaa_loc');
if (ls_val) {
const ls_json = JSON.parse(ls_val);
novi_uuid = ls_json.novi_uuid;
novi_full_name = ls_json.novi_full_name;
novi_email = ls_json.novi_email;
}
} catch (e) {
/* ignore */
}
}
post_comment_do['external_person_id'] = novi_uuid;
post_comment_do['full_name'] = novi_full_name;
post_comment_do['email'] = novi_email;
post_comment_do['notify'] = post_comment_di.notify ?? true; // Default to true if not found
post_comment_do['hide'] = post_comment_form.hide;

View File

@@ -891,7 +891,7 @@ $effect(() => {
name="external_person_id"
value={$idaa_slct.post_obj?.external_person_id
? $idaa_slct.post_obj?.external_person_id
: ''}
: ($idaa_loc.novi_uuid ?? '')}
readonly={!$ae_loc.administrator_access}
class="input preset-tonal-surface hover:preset-filled-surface-100-900 form-control w-96" />
{/if}
@@ -918,7 +918,7 @@ $effect(() => {
name="full_name"
value={$idaa_slct.post_obj?.full_name
? $idaa_slct?.post_obj.full_name
: $idaa_loc.novi_full_name}
: ($idaa_loc.novi_full_name ?? '')}
readonly={!$ae_loc.trusted_access}
class="input preset-tonal-surface hover:preset-filled-surface-100-900 form-control w-96" />
</label>

View File

@@ -183,11 +183,30 @@ let creating = $state(false); // true while create API call is in-flight
creating = true;
$idaa_slct.post_obj = {};
// Robust scavenging of identity from storage if store is null
let novi_uuid = $idaa_loc.novi_uuid;
let novi_full_name = $idaa_loc.novi_full_name;
let novi_email = $idaa_loc.novi_email;
if (!novi_uuid && typeof localStorage !== 'undefined') {
try {
const ls_val = localStorage.getItem('ae_idaa_loc');
if (ls_val) {
const ls_json = JSON.parse(ls_val);
novi_uuid = ls_json.novi_uuid;
novi_full_name = ls_json.novi_full_name;
novi_email = ls_json.novi_email;
}
} catch (e) {
/* ignore */
}
}
let data_kv = {
external_person_id: $idaa_loc.novi_uuid,
external_person_id: novi_uuid,
title: '',
full_name: $idaa_loc.novi_full_name,
email: $idaa_loc.novi_email,
full_name: novi_full_name,
email: novi_email,
enable: true
};
if (log_lvl) {

View File

@@ -446,8 +446,22 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
$idaa_slct.event_id = null;
$idaa_slct.event_obj = {};
let novi_uuid = $idaa_loc.novi_uuid;
if (!novi_uuid && typeof localStorage !== 'undefined') {
try {
const ls_val = localStorage.getItem('ae_idaa_loc');
if (ls_val) {
const ls_json = JSON.parse(ls_val);
novi_uuid = ls_json.novi_uuid;
}
} catch (e) {
// Ignore storage errors
}
}
let data_kv = {
name: 'Change NEW Recovery Meeting Name'
name: 'Change NEW Recovery Meeting Name',
external_person_id: novi_uuid
};
if (log_lvl) {
console.log(

View File

@@ -67,6 +67,7 @@ const mock_event = {
*/
function build_idaa_loc_defaults(opts: { edit_event_obj?: boolean } = {}) {
return {
__version: 1, // MUST MATCH IDAA_LOC_VERSION in store_versions.ts
ver: '2024-08-21_1646',
ver_idb: '2024-08-21_1645',
novi_uuid: TEST_NOVI_UUID,
@@ -111,6 +112,7 @@ async function setup_idaa_auth(page: any) {
trusted_access: true,
administrator_access: false,
access_type: 'trusted',
edit_mode: true,
iframe: false,
// Pre-seed the timezone list so the component renders <select required>
// with a real value rather than the fallback <input required value="">.
@@ -1033,3 +1035,48 @@ test.describe('IDAA Recovery Meetings — Field Save Payload Verification', () =
});
});
// =============================================================================
// Creation and Identity Linkage Tests
// Verifies that new meetings are correctly linked to the creating member's Novi ID.
// =============================================================================
test.describe('IDAA Recovery Meetings — Creation and Identity', () => {
test.beforeEach(async ({ page }) => {
page.on('pageerror', (err: Error) =>
console.error(`BROWSER ERROR: ${err.message}`)
);
await setup_idaa_auth(page);
await setup_api_mocks(page, TEST_EVENT_ID);
});
test('creating a new meeting sends external_person_id in POST payload', async ({ page }) => {
// 1. Start at the meeting list page
await page.goto('/idaa/recovery_meetings');
// 2. Click "Create New Meeting" button
// The component uses window.confirm() — we must handle the dialog
page.on('dialog', dialog => dialog.accept());
// Track API calls to capture the POST request (Creation, NOT search)
const post_promise = page.waitForRequest(
(req: any) =>
req.url().includes('/v3/crud/event') &&
!req.url().includes('search') &&
req.method() === 'POST',
{ timeout: 5000 }
);
await page.getByRole('button', { name: /Create New Meeting/ }).click();
// 3. Capture and verify the POST body
const post_req = await post_promise;
const post_body = JSON.parse(post_req.postData() ?? '{}');
expect(post_body.external_person_id, 'external_person_id in POST body').toBe(TEST_NOVI_UUID);
expect(post_body.name, 'default meeting name').toContain('Recovery Meeting Name');
});
});