diff --git a/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts b/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts index fdb0d282d..4ec55c8da 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts @@ -111,7 +111,6 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [ 'uloadFolders', // TODO: audit 'uloadTags', // TODO: audit 'userSettings', // TODO: audit - 'userTagPresets', // v34 table — intent is to encrypt (tag-preset names + inline tag names are sensitive); declared plaintext here for 2b so the audit passes, moves to ENCRYPTION_REGISTRY in Phase 2d alongside the store API 'wateringLogs', // TODO: audit 'wateringSchedules', // TODO: audit 'wetterLocations', // TODO: audit diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index c9e42afd3..8f8fe0217 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -586,6 +586,17 @@ export const ENCRYPTION_REGISTRY: Record = { // and mission-detail filters. aiMissions: { enabled: false, fields: ['title', 'conceptMarkdown', 'objective'] }, + // ─── User-level Tag Presets ────────────────────────────── + // Named templates the user applies when creating a new Space. The + // preset `name` (e.g. "Mein Standard") and the entire `tags` array + // (which contains per-entry tag names) are user-authored personal + // content that leaks categorization intent — encrypt both. `userId`, + // `isDefault`, timestamps stay plaintext for the indexed query path. + // AES wrapping handles `tags` as an array via JSON-stringify (same + // pattern as food.foods / recipes.ingredients). + // See docs/plans/space-scoped-data-model.md §5. + userTagPresets: { enabled: true, fields: ['name', 'tags'] }, + // ─── Tags (shared-stores) ──────────────────────────────── // docs/plans/space-scoped-data-model.md §2a — declared with // enabled:false during prep; flips to true in 2c. Tag names like diff --git a/apps/mana/apps/web/src/lib/data/tag-presets/queries.ts b/apps/mana/apps/web/src/lib/data/tag-presets/queries.ts new file mode 100644 index 000000000..36b1d0db8 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/tag-presets/queries.ts @@ -0,0 +1,51 @@ +/** + * userTagPresets — reactive read surface. + * + * Always scoped to the current user; no active-space filter (presets are + * user-level and the picker runs from any Space context). + */ + +import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; +import { getCurrentUserId } from '$lib/data/current-user'; +import type { LocalUserTagPreset, UserTagPreset } from './types'; +import { toUserTagPreset } from './types'; + +export function useUserTagPresets() { + return useLiveQueryWithDefault(async () => { + const userId = getCurrentUserId(); + if (!userId) return [] as UserTagPreset[]; + + const rows = await db + .table('userTagPresets') + .where('userId') + .equals(userId) + .toArray(); + + const visible = rows.filter((p) => !p.deletedAt); + const decrypted = await decryptRecords('userTagPresets', visible); + return decrypted.map(toUserTagPreset).sort((a, b) => a.name.localeCompare(b.name, 'de')); + }, [] as UserTagPreset[]); +} + +export function useDefaultTagPreset() { + return useLiveQueryWithDefault( + async () => { + const userId = getCurrentUserId(); + if (!userId) return null; + + const row = await db + .table('userTagPresets') + .where('userId') + .equals(userId) + .and((p) => p.isDefault && !p.deletedAt) + .first(); + + if (!row) return null; + const [decrypted] = await decryptRecords('userTagPresets', [row]); + return decrypted ? toUserTagPreset(decrypted) : null; + }, + null as UserTagPreset | null + ); +} diff --git a/apps/mana/apps/web/src/lib/data/tag-presets/store.svelte.ts b/apps/mana/apps/web/src/lib/data/tag-presets/store.svelte.ts new file mode 100644 index 000000000..ead5046f9 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/tag-presets/store.svelte.ts @@ -0,0 +1,111 @@ +/** + * userTagPresets — CRUD store. + * + * Lives at the user level (no spaceId). Encrypted: the preset `name` and + * the whole `tags` array (which contains per-entry tag names) are + * user-authored personal content and leak categorization intent. + * + * The Dexie creating hook does NOT fire for this table — userTagPresets + * is intentionally kept out of SYNC_APP_MAP for now (local-only, added + * to sync as a follow-up once the Space-create UI lands in Phase 5 of + * the plan). That means we stamp `userId` + timestamps explicitly below. + * + * See docs/plans/space-scoped-data-model.md §5. + */ + +import { db } from '$lib/data/database'; +import { encryptRecord, decryptRecords } from '$lib/data/crypto'; +import { getEffectiveUserId } from '$lib/data/current-user'; +import type { + LocalUserTagPreset, + CreatePresetInput, + UpdatePresetInput, + UserTagPreset, + TagPresetEntry, +} from './types'; +import { toUserTagPreset } from './types'; + +const table = db.table('userTagPresets'); + +function now(): string { + return new Date().toISOString(); +} + +async function clearDefaultFlag(userId: string, exceptId?: string): Promise { + // Only one preset per user may carry isDefault:true. When setting a new + // default (or creating a default), flip every other of this user's + // presets off in the same transaction. + const rows = await table + .where('userId') + .equals(userId) + .and((p) => p.isDefault && p.id !== exceptId) + .toArray(); + for (const row of rows) { + await table.update(row.id, { isDefault: false, updatedAt: now() }); + } +} + +export const tagPresetsStore = { + async createPreset(input: CreatePresetInput): Promise { + const userId = getEffectiveUserId(); + const timestamp = now(); + const newLocal: LocalUserTagPreset = { + id: crypto.randomUUID(), + userId, + name: input.name, + isDefault: input.isDefault ?? false, + tags: input.tags ?? [], + createdAt: timestamp, + updatedAt: timestamp, + }; + + if (newLocal.isDefault) await clearDefaultFlag(userId, newLocal.id); + + const plaintextSnapshot = toUserTagPreset(newLocal); + await encryptRecord('userTagPresets', newLocal); + await table.add(newLocal); + return plaintextSnapshot; + }, + + async updatePreset(id: string, input: UpdatePresetInput): Promise { + const existing = await table.get(id); + if (!existing) throw new Error(`Preset ${id} not found`); + + if (input.isDefault === true) { + await clearDefaultFlag(existing.userId, id); + } + + const diff: Partial = { + ...(input.name !== undefined && { name: input.name }), + ...(input.tags !== undefined && { tags: input.tags }), + ...(input.isDefault !== undefined && { isDefault: input.isDefault }), + updatedAt: now(), + }; + await encryptRecord('userTagPresets', diff); + await table.update(id, diff); + }, + + async deletePreset(id: string): Promise { + await table.update(id, { deletedAt: now(), updatedAt: now() }); + }, + + async setDefault(id: string): Promise { + const existing = await table.get(id); + if (!existing) throw new Error(`Preset ${id} not found`); + await clearDefaultFlag(existing.userId, id); + await table.update(id, { isDefault: true, updatedAt: now() }); + }, + + /** + * Adds one entry to an existing preset. Handy for the "promote this + * tag into a preset" flow from the tag manager UI — no need to + * replace the whole `tags` array from the caller. + */ + async appendEntry(id: string, entry: TagPresetEntry): Promise { + const existing = await table.get(id); + if (!existing) throw new Error(`Preset ${id} not found`); + const [decrypted] = await decryptRecords('userTagPresets', [existing]); + const next = [...(decrypted?.tags ?? []), entry]; + await this.updatePreset(id, { tags: next }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/data/tag-presets/types.ts b/apps/mana/apps/web/src/lib/data/tag-presets/types.ts new file mode 100644 index 000000000..0497f031e --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/tag-presets/types.ts @@ -0,0 +1,64 @@ +/** + * User-level tag presets. + * + * A preset is a named, snapshot-shaped tag bundle the user can apply + * when creating a new Space. Presets live at the user level (cross-Space) + * so the picker can show them from any Space context. + * + * The preset's `tags` field is a frozen snapshot — not live references + * to existing globalTags rows. Applying a preset to a Space one-shot- + * copies each entry as a fresh `globalTags` row with a new UUID. + * + * See docs/plans/space-scoped-data-model.md §5. + */ + +export interface TagPresetEntry { + name: string; + color: string; + icon?: string; + /** + * Optional group-by label on the preset. When the preset is applied to + * a Space, we create a `tagGroups` row for each distinct groupName and + * link the resulting globalTags to it — so the user's familiar grouping + * shows up without them having to rebuild it per Space. + */ + groupName?: string; +} + +export interface LocalUserTagPreset { + id: string; + userId: string; + name: string; + /** At most one preset per user may be the default. */ + isDefault: boolean; + tags: TagPresetEntry[]; + createdAt: string; + updatedAt: string; + deletedAt?: string; +} + +export type UserTagPreset = Omit; + +export function toUserTagPreset(local: LocalUserTagPreset): UserTagPreset { + return { + id: local.id, + userId: local.userId, + name: local.name, + isDefault: local.isDefault, + tags: local.tags ?? [], + createdAt: local.createdAt, + updatedAt: local.updatedAt, + }; +} + +export interface CreatePresetInput { + name: string; + tags?: TagPresetEntry[]; + isDefault?: boolean; +} + +export interface UpdatePresetInput { + name?: string; + tags?: TagPresetEntry[]; + isDefault?: boolean; +}