diff --git a/apps/manacore/apps/web/src/lib/data/database.ts b/apps/manacore/apps/web/src/lib/data/database.ts index 91772bee8..f61ff2f7f 100644 --- a/apps/manacore/apps/web/src/lib/data/database.ts +++ b/apps/manacore/apps/web/src/lib/data/database.ts @@ -9,6 +9,7 @@ import Dexie, { type EntityTable } from 'dexie'; import { trackFirstContent } from '$lib/stores/funnel-tracking'; +import { fire as fireTrigger } from '$lib/triggers/registry'; // ─── Database ────────────────────────────────────────────── @@ -22,6 +23,7 @@ db.version(1).stores({ // ─── Core / ManaCore (appId: 'manacore') ─── userSettings: 'id, key', dashboardConfigs: 'id', + automations: 'id, sourceApp, targetApp, enabled, [sourceApp+sourceCollection]', // ─── Todo (appId: 'todo') ─── tasks: @@ -206,7 +208,7 @@ db.version(1).stores({ // The SyncEngine uses this to group pending changes and push to /sync/{appId}. export const SYNC_APP_MAP: Record = { - manacore: ['userSettings', 'dashboardConfigs'], + manacore: ['userSettings', 'dashboardConfigs', 'automations'], todo: ['tasks', 'todoProjects', 'taskLabels', 'reminders', 'boardViews'], calendar: ['calendars', 'events', 'eventTags'], contacts: ['contacts', 'contactTags'], @@ -370,6 +372,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { createdAt: now, }); trackFirstContent(appId); + fireTrigger(appId, tableName, 'insert', { ...obj }); }); table.hook('updating', function (modifications, primKey) { @@ -389,6 +392,8 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { deletedAt: (modifications as Record).deletedAt as string | undefined, createdAt: now, }); + const op = (modifications as Record).deletedAt ? 'delete' : 'update'; + fireTrigger(appId, tableName, op, modifications as Record); }); } } diff --git a/apps/manacore/apps/web/src/lib/triggers/actions.ts b/apps/manacore/apps/web/src/lib/triggers/actions.ts new file mode 100644 index 000000000..3306ce665 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/triggers/actions.ts @@ -0,0 +1,41 @@ +/** + * Trigger Action Catalog + * + * Maps appId → actionName → handler function. + * All imports are lazy to avoid circular dependencies. + */ + +export type ActionHandler = ( + params: Record, + sourceData: Record +) => Promise; + +export const ACTIONS: Record> = { + habits: { + logHabit: async (params) => { + const { habitsStore } = await import('$lib/modules/habits/stores/habits.svelte'); + await habitsStore.logHabit(params.habitId, 'Auto: Trigger'); + }, + }, + todo: { + createTask: async (params, source) => { + const { tasksStore } = await import('$lib/modules/todo/stores/tasks.svelte'); + await tasksStore.createTask({ + title: params.title || (source.title as string) || 'Triggered Task', + }); + }, + }, + notes: { + createNote: async (params, source) => { + const { notesStore } = await import('$lib/modules/notes/stores/notes.svelte'); + await notesStore.createNote({ + title: params.title || (source.title as string) || 'Triggered Note', + content: params.content || '', + }); + }, + }, +}; + +export function getAction(appId: string, actionName: string): ActionHandler | undefined { + return ACTIONS[appId]?.[actionName]; +} diff --git a/apps/manacore/apps/web/src/lib/triggers/conditions.ts b/apps/manacore/apps/web/src/lib/triggers/conditions.ts new file mode 100644 index 000000000..2e7b43683 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/triggers/conditions.ts @@ -0,0 +1,33 @@ +/** + * Trigger Condition Evaluator + * + * Evaluates whether a data record matches a condition rule. + */ + +export type ConditionOp = 'contains' | 'equals' | 'startsWith' | 'matches'; + +export function evaluateCondition( + data: Record, + field: string, + op: ConditionOp, + value: string +): boolean { + const fieldValue = String(data[field] ?? '').toLowerCase(); + const checkValue = value.toLowerCase(); + switch (op) { + case 'contains': + return fieldValue.includes(checkValue); + case 'equals': + return fieldValue === checkValue; + case 'startsWith': + return fieldValue.startsWith(checkValue); + case 'matches': + try { + return new RegExp(checkValue, 'i').test(String(data[field] ?? '')); + } catch { + return false; + } + default: + return false; + } +} diff --git a/apps/manacore/apps/web/src/lib/triggers/index.ts b/apps/manacore/apps/web/src/lib/triggers/index.ts new file mode 100644 index 000000000..4092ed91d --- /dev/null +++ b/apps/manacore/apps/web/src/lib/triggers/index.ts @@ -0,0 +1,10 @@ +/** + * Trigger system — barrel exports. + */ + +export { register, unregister, unregisterAll, fire, getRegisteredTriggers } from './registry'; +export type { RegisteredTrigger } from './registry'; +export { evaluateCondition } from './conditions'; +export type { ConditionOp } from './conditions'; +export { ACTIONS, getAction } from './actions'; +export { loadAutomations } from './loader'; diff --git a/apps/manacore/apps/web/src/lib/triggers/loader.ts b/apps/manacore/apps/web/src/lib/triggers/loader.ts new file mode 100644 index 000000000..18c1aeec5 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/triggers/loader.ts @@ -0,0 +1,65 @@ +/** + * Automation Loader — Reads automation rules from IndexedDB and registers triggers. + * + * Called once at app startup. Watches for changes to re-register. + */ + +import { db } from '$lib/data/database'; +import { register, unregisterAll } from './registry'; +import { evaluateCondition, type ConditionOp } from './conditions'; +import { getAction } from './actions'; + +export interface LocalAutomation { + id: string; + name: string; + enabled: boolean; + sourceApp: string; + sourceCollection: string; + sourceOp: string; + conditionField?: string; + conditionOp?: ConditionOp; + conditionValue?: string; + targetApp: string; + targetAction: string; + targetParams?: Record; + deletedAt?: string; + createdAt?: string; + updatedAt?: string; +} + +/** + * Load all enabled automations from IndexedDB and register them as triggers. + */ +export async function loadAutomations(): Promise { + unregisterAll(); + + const all = await db.table('automations').toArray(); + const active = all.filter((a) => a.enabled && !a.deletedAt); + + for (const auto of active) { + const actionFn = getAction(auto.targetApp, auto.targetAction); + if (!actionFn) { + console.warn(`[Triggers] Unknown action: ${auto.targetApp}.${auto.targetAction}`); + continue; + } + + register({ + id: auto.id, + sourceApp: auto.sourceApp, + sourceCollection: auto.sourceCollection, + sourceOp: auto.sourceOp, + condition: + auto.conditionField && auto.conditionOp && auto.conditionValue + ? (data) => + evaluateCondition(data, auto.conditionField!, auto.conditionOp!, auto.conditionValue!) + : undefined, + action: async (data) => { + await actionFn(auto.targetParams ?? {}, data); + }, + }); + } + + if (active.length > 0) { + console.log(`[Triggers] Loaded ${active.length} automation(s)`); + } +} diff --git a/apps/manacore/apps/web/src/lib/triggers/registry.ts b/apps/manacore/apps/web/src/lib/triggers/registry.ts new file mode 100644 index 000000000..392ab7893 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/triggers/registry.ts @@ -0,0 +1,77 @@ +/** + * Trigger Registry — Cross-Module Automation Engine + * + * Listens to Dexie write operations and fires matching triggers. + * Triggers are evaluated synchronously, actions run async (fire-and-forget). + */ + +type TriggerHandler = (data: Record) => Promise | void; + +export interface RegisteredTrigger { + id: string; + sourceApp: string; + sourceCollection: string; + sourceOp: string; + condition?: (data: Record) => boolean; + action: TriggerHandler; +} + +const triggers: RegisteredTrigger[] = []; + +/** Prevent triggers from firing triggers (infinite loops). */ +let _firingTriggers = false; + +export function register(trigger: RegisteredTrigger): void { + // Avoid duplicates + if (triggers.some((t) => t.id === trigger.id)) return; + triggers.push(trigger); +} + +export function unregister(id: string): void { + const idx = triggers.findIndex((t) => t.id === id); + if (idx !== -1) triggers.splice(idx, 1); +} + +export function unregisterAll(): void { + triggers.length = 0; +} + +/** + * Fire matching triggers for a database write. + * Called from Dexie hooks in database.ts. + */ +export function fire( + appId: string, + collection: string, + op: string, + data: Record +): void { + if (_firingTriggers) return; + + const matching = triggers.filter( + (t) => t.sourceApp === appId && t.sourceCollection === collection && t.sourceOp === op + ); + + if (matching.length === 0) return; + + _firingTriggers = true; + + for (const trigger of matching) { + try { + if (trigger.condition && !trigger.condition(data)) continue; + + // Fire-and-forget — don't block the Dexie hook + Promise.resolve(trigger.action(data)).catch((err) => { + console.error(`[Trigger] Action failed for ${trigger.id}:`, err); + }); + } catch (err) { + console.error(`[Trigger] Error evaluating ${trigger.id}:`, err); + } + } + + _firingTriggers = false; +} + +export function getRegisteredTriggers(): readonly RegisteredTrigger[] { + return triggers; +} diff --git a/apps/manacore/apps/web/src/routes/+layout.svelte b/apps/manacore/apps/web/src/routes/+layout.svelte index cd37dad2b..9d7f9307b 100644 --- a/apps/manacore/apps/web/src/routes/+layout.svelte +++ b/apps/manacore/apps/web/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import { onMount } from 'svelte'; import { theme } from '$lib/stores/theme'; import { authStore } from '$lib/stores/auth.svelte'; + import { loadAutomations } from '$lib/triggers'; let { children } = $props(); @@ -13,6 +14,9 @@ // Initialize auth await authStore.initialize(); + // Load cross-module automation triggers + await loadAutomations(); + return () => { cleanupTheme(); };