mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(tag-presets): Phase 2d.1 — CRUD store + encryption for user-level presets
First wire-up of the userTagPresets surface from Phase 2b's v34 schema. This is the store layer only — Space-create UI integration + the apply-preset-to-space flow land in a follow-up commit alongside the SpaceCreateDialog changes. - lib/data/tag-presets/types.ts: LocalUserTagPreset shape + inline TagPresetEntry + toUserTagPreset converter. - lib/data/tag-presets/store.svelte.ts: createPreset / updatePreset / deletePreset / setDefault / appendEntry. Stamps userId explicitly because userTagPresets is kept out of SYNC_APP_MAP (the Dexie creating-hook only fires for sync tables). At-most-one-default-per- user invariant enforced by clearDefaultFlag() before writes that set isDefault=true. - lib/data/tag-presets/queries.ts: useUserTagPresets + useDefaultTagPreset live queries. User-scoped, no active-space filter (presets show from any Space context). - crypto: move userTagPresets from plaintext-allowlist to ENCRYPTION_REGISTRY with fields ['name', 'tags']. AES wrapping handles the tags array via JSON-stringify, same pattern as food.foods. Crypto audit: 196 tables (95 encrypted, +1 userTagPresets). Type-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4d91e2daad
commit
35d9e023a6
5 changed files with 237 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -586,6 +586,17 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
// 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
|
||||
|
|
|
|||
51
apps/mana/apps/web/src/lib/data/tag-presets/queries.ts
Normal file
51
apps/mana/apps/web/src/lib/data/tag-presets/queries.ts
Normal file
|
|
@ -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<LocalUserTagPreset>('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<LocalUserTagPreset>('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
|
||||
);
|
||||
}
|
||||
111
apps/mana/apps/web/src/lib/data/tag-presets/store.svelte.ts
Normal file
111
apps/mana/apps/web/src/lib/data/tag-presets/store.svelte.ts
Normal file
|
|
@ -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<LocalUserTagPreset>('userTagPresets');
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
async function clearDefaultFlag(userId: string, exceptId?: string): Promise<void> {
|
||||
// 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<UserTagPreset> {
|
||||
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<void> {
|
||||
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<LocalUserTagPreset> = {
|
||||
...(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<void> {
|
||||
await table.update(id, { deletedAt: now(), updatedAt: now() });
|
||||
},
|
||||
|
||||
async setDefault(id: string): Promise<void> {
|
||||
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<void> {
|
||||
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 });
|
||||
},
|
||||
};
|
||||
64
apps/mana/apps/web/src/lib/data/tag-presets/types.ts
Normal file
64
apps/mana/apps/web/src/lib/data/tag-presets/types.ts
Normal file
|
|
@ -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<LocalUserTagPreset, 'deletedAt'>;
|
||||
|
||||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue