diff --git a/apps/mana/apps/web/package.json b/apps/mana/apps/web/package.json index cc19c636c..9d1a08bad 100644 --- a/apps/mana/apps/web/package.json +++ b/apps/mana/apps/web/package.json @@ -66,6 +66,7 @@ "@mana/shared-icons": "workspace:*", "@mana/shared-links": "workspace:*", "@mana/shared-llm": "workspace:*", + "@mana/shared-privacy": "workspace:*", "@mana/shared-stores": "workspace:*", "@mana/shared-tags": "workspace:*", "@mana/shared-tailwind": "workspace:*", diff --git a/apps/mana/apps/web/src/lib/data/events/catalog.ts b/apps/mana/apps/web/src/lib/data/events/catalog.ts index 8d006dfe7..84cb7f5f9 100644 --- a/apps/mana/apps/web/src/lib/data/events/catalog.ts +++ b/apps/mana/apps/web/src/lib/data/events/catalog.ts @@ -613,6 +613,17 @@ export type BodyEventType = | 'MeasurementLogged' | 'EnergyCheckLogged'; +// ── Visibility (Cross-Module) ─────────────────────── +// Emitted by any module whose records carry a `visibility` field when +// the user flips it (typically via ). The payload type +// lives in @mana/shared-privacy so the event shape stays aligned with +// the primitives. See docs/plans/visibility-system.md. + +import type { VisibilityChangedPayload } from '@mana/shared-privacy'; +export type { VisibilityChangedPayload }; + +export type VisibilityEventType = 'VisibilityChanged'; + // ── System Events (Goals, Companion) ──────────────── export interface GoalReachedPayload { @@ -667,7 +678,8 @@ export type ManaEventType = | CompanionEventType | SocialEventsEventType | BodyEventType - | SystemEventType; + | SystemEventType + | VisibilityEventType; /** * Discriminated union of all domain events. @@ -785,4 +797,6 @@ export type ManaEvent = | DomainEvent<'EnergyCheckLogged', EnergyCheckLoggedPayload> // System | DomainEvent<'GoalReached', GoalReachedPayload> - | DomainEvent<'GoalProgress', GoalProgressPayload>; + | DomainEvent<'GoalProgress', GoalProgressPayload> + // Visibility (cross-module) + | DomainEvent<'VisibilityChanged', VisibilityChangedPayload>; diff --git a/apps/mana/apps/web/src/lib/modules/library/ListView.svelte b/apps/mana/apps/web/src/lib/modules/library/ListView.svelte index 52d53dec2..f6eea123b 100644 --- a/apps/mana/apps/web/src/lib/modules/library/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/library/ListView.svelte @@ -109,6 +109,7 @@ : presetKind() === 'series' ? { kind: 'series', watched: [] } : { kind: 'comic' }, + visibility: 'private', createdAt: '', updatedAt: '', id: '', diff --git a/apps/mana/apps/web/src/lib/modules/library/queries.ts b/apps/mana/apps/web/src/lib/modules/library/queries.ts index 2180a0d15..1de25cd1c 100644 --- a/apps/mana/apps/web/src/lib/modules/library/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/library/queries.ts @@ -32,6 +32,10 @@ export function toLibraryEntry(local: LocalLibraryEntry): LibraryEntry { times: local.times ?? 0, externalIds: local.externalIds ?? null, details: local.details, + // Legacy rows pre-dating the visibility pilot default to 'space' + // (the pre-pilot stamp that Dexie hooks wrote). New rows get the + // space-type-aware default at create time in entries.svelte.ts. + visibility: local.visibility ?? 'space', createdAt: local.createdAt ?? now, updatedAt: local.updatedAt ?? now, }; diff --git a/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts b/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts index d680458fb..a1f9b40b9 100644 --- a/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts @@ -7,6 +7,13 @@ import { encryptRecord } from '$lib/data/crypto'; import { emitDomainEvent } from '$lib/data/events'; +import { getActiveSpace } from '$lib/data/scope'; +import { getEffectiveUserId } from '$lib/data/current-user'; +import { + defaultVisibilityFor, + generateUnlistedToken, + type VisibilityLevel, +} from '@mana/shared-privacy'; import { libraryEntryTable } from '../collections'; import { toLibraryEntry } from '../queries'; import type { @@ -78,6 +85,10 @@ export const libraryEntriesStore = { times: 0, externalIds: input.externalIds ?? null, details, + // Pre-populate the visibility field so the Dexie hook's generic + // 'space' fallback doesn't fire for personal-space entries (which + // should default to 'private' per the unified visibility system). + visibility: defaultVisibilityFor(getActiveSpace()?.type), }; const snapshot = toLibraryEntry({ ...newLocal }); await encryptRecord('libraryEntries', newLocal); @@ -188,4 +199,41 @@ export const libraryEntriesStore = { }); emitDomainEvent('LibraryEntryDeleted', 'library', 'libraryEntries', id, { entryId: id }); }, + + /** + * Flip the visibility of an entry. Mints an unlisted token on first + * transition to 'unlisted' and wipes it when moving back to anything + * else, so a revoked link can't be silently re-activated. Emits a + * cross-module `VisibilityChanged` event so the Workbench timeline + + * audit surfaces pick it up. + * + * No-op if the level is already what the user selected. + */ + async setVisibility(id: string, next: VisibilityLevel) { + const existing = await libraryEntryTable.get(id); + if (!existing) throw new Error(`Library entry ${id} not found`); + const before: VisibilityLevel = existing.visibility ?? 'space'; + if (before === next) return; + + const now = new Date().toISOString(); + const patch: Partial = { + visibility: next, + visibilityChangedAt: now, + visibilityChangedBy: getEffectiveUserId(), + updatedAt: now, + }; + if (next === 'unlisted' && !existing.unlistedToken) { + patch.unlistedToken = generateUnlistedToken(); + } else if (next !== 'unlisted' && existing.unlistedToken) { + patch.unlistedToken = undefined; + } + await libraryEntryTable.update(id, patch); + + emitDomainEvent('VisibilityChanged', 'library', 'libraryEntries', id, { + recordId: id, + collection: 'libraryEntries', + before, + after: next, + }); + }, }; diff --git a/apps/mana/apps/web/src/lib/modules/library/types.ts b/apps/mana/apps/web/src/lib/modules/library/types.ts index 2d6466642..6518d94af 100644 --- a/apps/mana/apps/web/src/lib/modules/library/types.ts +++ b/apps/mana/apps/web/src/lib/modules/library/types.ts @@ -6,6 +6,7 @@ */ import type { BaseRecord } from '@mana/local-store'; +import type { VisibilityLevel } from '@mana/shared-privacy'; // ─── Discriminators & Enums ────────────────────────────── @@ -83,6 +84,24 @@ export interface LocalLibraryEntry extends BaseRecord { times: number; externalIds?: LibraryExternalIds | null; details: LibraryDetails; + /** + * Visibility level — pilot of the unified privacy system (see + * docs/plans/visibility-system.md). Optional on the local record + * because existing rows pre-date the field; the Dexie hook stamps + * 'space' as the structural default. `toLibraryEntry` narrows to a + * non-optional VisibilityLevel for callers. + */ + visibility?: VisibilityLevel; + /** ISO timestamp of the last visibility flip — useful for audit. */ + visibilityChangedAt?: string; + /** userId who made the last flip. */ + visibilityChangedBy?: string; + /** + * 32-char base64url token for unlisted-mode. Set when visibility is + * flipped to 'unlisted' and the record doesn't yet have one; cleared + * when visibility moves back to anything else. + */ + unlistedToken?: string; } // ─── Domain Type (plaintext, for UI) ───────────────────── @@ -107,6 +126,7 @@ export interface LibraryEntry { times: number; externalIds: LibraryExternalIds | null; details: LibraryDetails; + visibility: VisibilityLevel; createdAt: string; updatedAt: string; } diff --git a/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte index dfa11ec0d..6f1a2774d 100644 --- a/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte @@ -1,5 +1,6 @@