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).
|
`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
|
### Permission Upgrade Rule
|
||||||
```
|
```
|
||||||
// RULE: Only UPGRADE to Novi-based permissions, NEVER downgrade.
|
// 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({
|
let post_comment_form = $state({
|
||||||
content: $idaa_slct.post_comment_obj?.content ?? '',
|
content: $idaa_slct.post_comment_obj?.content ?? '',
|
||||||
anonymous: $idaa_slct.post_comment_obj?.anonymous ?? false,
|
anonymous: $idaa_slct.post_comment_obj?.anonymous ?? false,
|
||||||
external_person_id: $idaa_slct.post_comment_obj?.external_person_id ?? '',
|
external_person_id:
|
||||||
full_name: $idaa_slct.post_comment_obj?.full_name ?? '',
|
$idaa_slct.post_comment_obj?.external_person_id ||
|
||||||
email: $idaa_slct.post_comment_obj?.email ?? '',
|
$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,
|
enable: $idaa_slct.post_comment_obj?.enable ?? true,
|
||||||
hide: $idaa_slct.post_comment_obj?.hide ?? false,
|
hide: $idaa_slct.post_comment_obj?.hide ?? false,
|
||||||
priority: $idaa_slct.post_comment_obj?.priority ?? 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
|
// SVELTE 5: Map values from local form state to payload
|
||||||
post_comment_do['content'] = content_new_html;
|
post_comment_do['content'] = content_new_html;
|
||||||
post_comment_do['anonymous'] = post_comment_form.anonymous;
|
post_comment_do['anonymous'] = post_comment_form.anonymous;
|
||||||
post_comment_do['external_person_id'] =
|
|
||||||
post_comment_form.external_person_id;
|
// Robust scavenging of identity from storage if form state is missing it
|
||||||
post_comment_do['full_name'] = post_comment_form.full_name;
|
let novi_uuid = post_comment_form.external_person_id;
|
||||||
post_comment_do['email'] = post_comment_form.email;
|
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['notify'] = post_comment_di.notify ?? true; // Default to true if not found
|
||||||
|
|
||||||
post_comment_do['hide'] = post_comment_form.hide;
|
post_comment_do['hide'] = post_comment_form.hide;
|
||||||
|
|||||||
@@ -891,7 +891,7 @@ $effect(() => {
|
|||||||
name="external_person_id"
|
name="external_person_id"
|
||||||
value={$idaa_slct.post_obj?.external_person_id
|
value={$idaa_slct.post_obj?.external_person_id
|
||||||
? $idaa_slct.post_obj?.external_person_id
|
? $idaa_slct.post_obj?.external_person_id
|
||||||
: ''}
|
: ($idaa_loc.novi_uuid ?? '')}
|
||||||
readonly={!$ae_loc.administrator_access}
|
readonly={!$ae_loc.administrator_access}
|
||||||
class="input preset-tonal-surface hover:preset-filled-surface-100-900 form-control w-96" />
|
class="input preset-tonal-surface hover:preset-filled-surface-100-900 form-control w-96" />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -918,7 +918,7 @@ $effect(() => {
|
|||||||
name="full_name"
|
name="full_name"
|
||||||
value={$idaa_slct.post_obj?.full_name
|
value={$idaa_slct.post_obj?.full_name
|
||||||
? $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}
|
readonly={!$ae_loc.trusted_access}
|
||||||
class="input preset-tonal-surface hover:preset-filled-surface-100-900 form-control w-96" />
|
class="input preset-tonal-surface hover:preset-filled-surface-100-900 form-control w-96" />
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -183,11 +183,30 @@ let creating = $state(false); // true while create API call is in-flight
|
|||||||
creating = true;
|
creating = true;
|
||||||
$idaa_slct.post_obj = {};
|
$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 = {
|
let data_kv = {
|
||||||
external_person_id: $idaa_loc.novi_uuid,
|
external_person_id: novi_uuid,
|
||||||
title: '',
|
title: '',
|
||||||
full_name: $idaa_loc.novi_full_name,
|
full_name: novi_full_name,
|
||||||
email: $idaa_loc.novi_email,
|
email: novi_email,
|
||||||
enable: true
|
enable: true
|
||||||
};
|
};
|
||||||
if (log_lvl) {
|
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_id = null;
|
||||||
$idaa_slct.event_obj = {};
|
$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 = {
|
let data_kv = {
|
||||||
name: 'Change NEW Recovery Meeting Name'
|
name: 'Change NEW Recovery Meeting Name',
|
||||||
|
external_person_id: novi_uuid
|
||||||
};
|
};
|
||||||
if (log_lvl) {
|
if (log_lvl) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const mock_event = {
|
|||||||
*/
|
*/
|
||||||
function build_idaa_loc_defaults(opts: { edit_event_obj?: boolean } = {}) {
|
function build_idaa_loc_defaults(opts: { edit_event_obj?: boolean } = {}) {
|
||||||
return {
|
return {
|
||||||
|
__version: 1, // MUST MATCH IDAA_LOC_VERSION in store_versions.ts
|
||||||
ver: '2024-08-21_1646',
|
ver: '2024-08-21_1646',
|
||||||
ver_idb: '2024-08-21_1645',
|
ver_idb: '2024-08-21_1645',
|
||||||
novi_uuid: TEST_NOVI_UUID,
|
novi_uuid: TEST_NOVI_UUID,
|
||||||
@@ -111,6 +112,7 @@ async function setup_idaa_auth(page: any) {
|
|||||||
trusted_access: true,
|
trusted_access: true,
|
||||||
administrator_access: false,
|
administrator_access: false,
|
||||||
access_type: 'trusted',
|
access_type: 'trusted',
|
||||||
|
edit_mode: true,
|
||||||
iframe: false,
|
iframe: false,
|
||||||
// Pre-seed the timezone list so the component renders <select required>
|
// Pre-seed the timezone list so the component renders <select required>
|
||||||
// with a real value rather than the fallback <input required value="">.
|
// 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