diff --git a/apps/backend/src/models/notification-rule.model.ts b/apps/backend/src/models/notification-rule.model.ts new file mode 100644 index 0000000..b53afd2 --- /dev/null +++ b/apps/backend/src/models/notification-rule.model.ts @@ -0,0 +1,31 @@ +import mongoose, { type InferSchemaType } from 'mongoose'; + +const notificationRuleSchema = new mongoose.Schema( + { + name: { type: String, required: true }, + enabled: { type: Boolean, default: true }, + priority: { type: Number, default: 50 }, + + // Match scope + packageName: { type: String }, // optional: limit to a package/channel + keywords: { type: [String], default: [] }, // any keyword match in title/body + pattern: { type: String }, // optional regex string + + // Action + action: { type: String, enum: ['record', 'ignore'], default: 'record' }, + category: { type: String }, // override category when action is record + type: { type: String, enum: ['income', 'expense'] }, // override type + amountPattern: { type: String }, // custom regex for extracting amount + + // Telemetry + matchCount: { type: Number, default: 0 }, + lastMatchedAt: { type: Date } + }, + { timestamps: true } +); + +export type NotificationRuleDocument = InferSchemaType; + +export const NotificationRuleModel = + mongoose.models.NotificationRule ?? mongoose.model('NotificationRule', notificationRuleSchema); + diff --git a/apps/backend/src/modules/notifications/notification-rules.controller.ts b/apps/backend/src/modules/notifications/notification-rules.controller.ts new file mode 100644 index 0000000..f14a9f2 --- /dev/null +++ b/apps/backend/src/modules/notifications/notification-rules.controller.ts @@ -0,0 +1,41 @@ +import type { Request, Response } from 'express'; +import { z } from 'zod'; +import { createRule, deleteRule, listRules, updateRule } from './notification-rules.service.js'; + +const ruleSchema = z.object({ + name: z.string().min(1), + enabled: z.boolean().optional(), + priority: z.number().int().min(0).max(100).optional(), + packageName: z.string().optional(), + keywords: z.array(z.string()).optional(), + pattern: z.string().optional(), + action: z.enum(['record', 'ignore']).optional(), + category: z.string().optional(), + type: z.enum(['income', 'expense']).optional(), + amountPattern: z.string().optional() +}); + +export async function listRulesHandler(_req: Request, res: Response) { + const data = await listRules(); + return res.json({ data }); +} + +export async function createRuleHandler(req: Request, res: Response) { + const payload = ruleSchema.parse(req.body); + const data = await createRule(payload as any); + return res.status(201).json({ data }); +} + +export async function updateRuleHandler(req: Request, res: Response) { + const payload = ruleSchema.partial().parse(req.body); + const data = await updateRule(req.params.id, payload as any); + if (!data) return res.status(404).json({ message: 'Rule not found' }); + return res.json({ data }); +} + +export async function deleteRuleHandler(req: Request, res: Response) { + const ok = await deleteRule(req.params.id); + if (!ok) return res.status(404).json({ message: 'Rule not found' }); + return res.status(204).send(); +} + diff --git a/apps/backend/src/modules/notifications/notification-rules.router.ts b/apps/backend/src/modules/notifications/notification-rules.router.ts new file mode 100644 index 0000000..c4f010a --- /dev/null +++ b/apps/backend/src/modules/notifications/notification-rules.router.ts @@ -0,0 +1,27 @@ +import { Router } from 'express'; +import { + listRulesHandler, + createRuleHandler, + updateRuleHandler, + deleteRuleHandler +} from './notification-rules.controller.js'; +import { validateRequest } from '../../middlewares/validate-request.js'; +import { z } from 'zod'; + +const router = Router(); + +router.get('/', listRulesHandler); +router.post('/', createRuleHandler); +router.patch( + '/:id', + validateRequest({ params: z.object({ id: z.string().min(1) }) }), + updateRuleHandler +); +router.delete( + '/:id', + validateRequest({ params: z.object({ id: z.string().min(1) }) }), + deleteRuleHandler +); + +export default router; + diff --git a/apps/backend/src/modules/notifications/notification-rules.service.ts b/apps/backend/src/modules/notifications/notification-rules.service.ts new file mode 100644 index 0000000..d73d269 --- /dev/null +++ b/apps/backend/src/modules/notifications/notification-rules.service.ts @@ -0,0 +1,148 @@ +import mongoose from 'mongoose'; +import { + NotificationRuleModel, + type NotificationRuleDocument +} from '../../models/notification-rule.model.js'; + +export interface NotificationRuleDto { + id: string; + name: string; + enabled: boolean; + priority: number; + packageName?: string; + keywords?: string[]; + pattern?: string; + action: 'record' | 'ignore'; + category?: string; + type?: 'income' | 'expense'; + amountPattern?: string; + matchCount: number; + lastMatchedAt?: string; + createdAt?: string; + updatedAt?: string; +} + +const toDto = (doc: NotificationRuleDocument & { _id: mongoose.Types.ObjectId }): NotificationRuleDto => ({ + id: doc._id.toString(), + name: doc.name, + enabled: doc.enabled ?? true, + priority: doc.priority ?? 50, + packageName: doc.packageName ?? undefined, + keywords: doc.keywords ?? [], + pattern: doc.pattern ?? undefined, + action: (doc.action as 'record' | 'ignore') ?? 'record', + category: doc.category ?? undefined, + type: doc.type as any, + amountPattern: doc.amountPattern ?? undefined, + matchCount: doc.matchCount ?? 0, + lastMatchedAt: doc.lastMatchedAt ? new Date(doc.lastMatchedAt).toISOString() : undefined, + createdAt: (doc as any).createdAt ? new Date((doc as any).createdAt).toISOString() : undefined, + updatedAt: (doc as any).updatedAt ? new Date((doc as any).updatedAt).toISOString() : undefined +}); + +export async function listRules(): Promise { + const docs = await NotificationRuleModel.find().sort({ priority: -1, createdAt: 1 }).lean().exec(); + return docs.map((d) => toDto(d as NotificationRuleDocument & { _id: mongoose.Types.ObjectId })); +} + +export async function createRule(payload: Omit): Promise { + const created = await NotificationRuleModel.create({ + name: payload.name, + enabled: payload.enabled ?? true, + priority: payload.priority ?? 50, + packageName: payload.packageName ?? undefined, + keywords: payload.keywords ?? [], + pattern: payload.pattern ?? undefined, + action: payload.action ?? 'record', + category: payload.category ?? undefined, + type: payload.type ?? undefined, + amountPattern: payload.amountPattern ?? undefined + }); + const doc = await NotificationRuleModel.findById(created._id).lean().exec(); + return toDto(doc as NotificationRuleDocument & { _id: mongoose.Types.ObjectId }); +} + +export async function updateRule( + id: string, + payload: Partial> +): Promise { + const updated = await NotificationRuleModel.findByIdAndUpdate( + id, + { + $set: { + ...payload + } + }, + { new: true } + ) + .lean() + .exec(); + return updated ? toDto(updated) : null; +} + +export async function deleteRule(id: string): Promise { + const res = await NotificationRuleModel.findByIdAndDelete(id).exec(); + return res !== null; +} + +export interface RuleMatchResult { + ignore: boolean; + overrides: { + category?: string; + type?: 'income' | 'expense'; + amountPattern?: string; + }; + matchedRule?: NotificationRuleDto; +} + +function safeRegex(pattern?: string): RegExp | null { + if (!pattern) return null; + try { + return new RegExp(pattern, 'i'); + } catch { + return null; + } +} + +export async function applyNotificationRules(input: { + packageName: string; + title: string; + body: string; +}): Promise { + const text = `${input.title ?? ''} ${input.body ?? ''}`; + const rules = await NotificationRuleModel.find({ enabled: true }).sort({ priority: -1, createdAt: 1 }).lean().exec(); + + for (const r of rules as Array) { + if (r.packageName && r.packageName !== input.packageName) continue; + + let matched = false; + if (r.keywords && r.keywords.length > 0) { + matched = r.keywords.some((kw) => kw && (text.includes(kw) || input.title?.includes(kw))); + } + if (!matched && r.pattern) { + const regex = safeRegex(r.pattern); + if (regex && regex.test(text)) matched = true; + } + + if (matched) { + // update telemetry + void NotificationRuleModel.updateOne( + { _id: r._id }, + { $inc: { matchCount: 1 }, $set: { lastMatchedAt: new Date() } } + ).exec(); + + return { + ignore: r.action === 'ignore', + overrides: { + category: r.category ?? undefined, + type: (r.type as any) ?? undefined, + amountPattern: r.amountPattern ?? undefined + }, + matchedRule: toDto(r) + }; + } + } + + return { ignore: false, overrides: {} }; +} + diff --git a/apps/backend/src/modules/transactions/transactions.controller.ts b/apps/backend/src/modules/transactions/transactions.controller.ts index 63d6a16..a1d4f5a 100644 --- a/apps/backend/src/modules/transactions/transactions.controller.ts +++ b/apps/backend/src/modules/transactions/transactions.controller.ts @@ -95,6 +95,10 @@ export const createTransactionFromNotificationHandler = async (req: Request, res receivedAt: new Date(receivedAt) }); + if (!transaction) { + return res.status(204).send(); + } + return res.status(202).json({ message: 'Notification ingested and awaiting confirmation', data: transaction diff --git a/apps/backend/src/modules/transactions/transactions.service.ts b/apps/backend/src/modules/transactions/transactions.service.ts index 37f0314..76ec0ae 100644 --- a/apps/backend/src/modules/transactions/transactions.service.ts +++ b/apps/backend/src/modules/transactions/transactions.service.ts @@ -7,6 +7,7 @@ import { getChannelByPackage } from '../notifications/notification-channels.serv import { getDefaultUserId } from '../../services/user-context.js'; import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js'; import { parseNotificationPayload } from './notification.parser.js'; +import { applyNotificationRules } from '../notifications/notification-rules.service.js'; export interface TransactionDto { id: string; @@ -156,19 +157,35 @@ interface NotificationPayload { receivedAt: Date; } -export async function ingestNotification(payload: NotificationPayload): Promise { +export async function ingestNotification(payload: NotificationPayload): Promise { const now = payload.receivedAt; const channel = await getChannelByPackage(payload.packageName); if (!channel || !channel.enabled) { throw Object.assign(new Error('Notification channel disabled'), { statusCode: 403 }); } + // Apply DB-based rules first (blacklist/overrides) + const ruleResult = await applyNotificationRules({ + packageName: payload.packageName, + title: payload.title ?? '', + body: payload.body ?? '' + }); + + if (ruleResult.ignore) { + return null; // blacklisted by rule + } + const template = channel.templates?.find((tpl: { keywords?: string[] }) => tpl.keywords?.some((keyword: string) => keyword && (payload.title?.includes(keyword) || payload.body?.includes(keyword))) ) ?? null; - const parsed = parseNotificationPayload(payload.title ?? '', payload.body ?? '', template); + const mergedTemplate = { + ...(template ?? {}), + ...(ruleResult.overrides ?? {}) + } as Parameters[2]; + + const parsed = parseNotificationPayload(payload.title ?? '', payload.body ?? '', mergedTemplate); const hashSource = `${payload.packageName}|${payload.title}|${payload.body}|${now.getTime()}`; const notificationHash = createHash('sha256').update(hashSource).digest('hex'); diff --git a/apps/backend/src/routes/index.ts b/apps/backend/src/routes/index.ts index c971d8b..c539488 100644 --- a/apps/backend/src/routes/index.ts +++ b/apps/backend/src/routes/index.ts @@ -4,6 +4,7 @@ import transactionsRouter from '../modules/transactions/transactions.router.js'; import analysisRouter from '../modules/analysis/analysis.router.js'; import budgetsRouter from '../modules/budgets/budgets.router.js'; import notificationsRouter from '../modules/notifications/notifications.router.js'; +import notificationRulesRouter from '../modules/notifications/notification-rules.router.js'; const router = Router(); @@ -16,5 +17,6 @@ router.use('/transactions', transactionsRouter); router.use('/analysis', analysisRouter); router.use('/budgets', budgetsRouter); router.use('/notifications', notificationsRouter); +router.use('/notification-rules', notificationRulesRouter); export { router }; diff --git a/apps/frontend/src/features/dashboard/pages/DashboardPage.vue b/apps/frontend/src/features/dashboard/pages/DashboardPage.vue index b11767d..70fe64e 100644 --- a/apps/frontend/src/features/dashboard/pages/DashboardPage.vue +++ b/apps/frontend/src/features/dashboard/pages/DashboardPage.vue @@ -1,5 +1,5 @@