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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user