mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
feat(manacore/web): add cross-module trigger registry for automations
New trigger system that listens to Dexie write operations and fires configurable actions in other modules. Automations are stored in IndexedDB and loaded at app startup. Example: calendar event "Basketball" created → habit "Basketball" logged. Architecture: Dexie hooks → fire() → registry → condition check → action Actions are async fire-and-forget with loop prevention. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2241663823
commit
ee03782fde
7 changed files with 236 additions and 1 deletions
|
|
@ -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<string, string[]> = {
|
||||
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<string, unknown>).deletedAt as string | undefined,
|
||||
createdAt: now,
|
||||
});
|
||||
const op = (modifications as Record<string, unknown>).deletedAt ? 'delete' : 'update';
|
||||
fireTrigger(appId, tableName, op, modifications as Record<string, unknown>);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
41
apps/manacore/apps/web/src/lib/triggers/actions.ts
Normal file
41
apps/manacore/apps/web/src/lib/triggers/actions.ts
Normal file
|
|
@ -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<string, string>,
|
||||
sourceData: Record<string, unknown>
|
||||
) => Promise<void>;
|
||||
|
||||
export const ACTIONS: Record<string, Record<string, ActionHandler>> = {
|
||||
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];
|
||||
}
|
||||
33
apps/manacore/apps/web/src/lib/triggers/conditions.ts
Normal file
33
apps/manacore/apps/web/src/lib/triggers/conditions.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
10
apps/manacore/apps/web/src/lib/triggers/index.ts
Normal file
10
apps/manacore/apps/web/src/lib/triggers/index.ts
Normal file
|
|
@ -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';
|
||||
65
apps/manacore/apps/web/src/lib/triggers/loader.ts
Normal file
65
apps/manacore/apps/web/src/lib/triggers/loader.ts
Normal file
|
|
@ -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<string, string>;
|
||||
deletedAt?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all enabled automations from IndexedDB and register them as triggers.
|
||||
*/
|
||||
export async function loadAutomations(): Promise<void> {
|
||||
unregisterAll();
|
||||
|
||||
const all = await db.table<LocalAutomation>('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)`);
|
||||
}
|
||||
}
|
||||
77
apps/manacore/apps/web/src/lib/triggers/registry.ts
Normal file
77
apps/manacore/apps/web/src/lib/triggers/registry.ts
Normal file
|
|
@ -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<string, unknown>) => Promise<void> | void;
|
||||
|
||||
export interface RegisteredTrigger {
|
||||
id: string;
|
||||
sourceApp: string;
|
||||
sourceCollection: string;
|
||||
sourceOp: string;
|
||||
condition?: (data: Record<string, unknown>) => 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<string, unknown>
|
||||
): 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue