From 0c6a5131eb65837c36850924d34378226cfc3928 Mon Sep 17 00:00:00 2001 From: yasser Date: Mon, 23 Feb 2026 15:55:59 +0000 Subject: [PATCH 01/13] infrastructure adapters --- package.json | 26 ++ src/infra/README.md | 377 +++++++++++++++++- src/infra/index.ts | 21 + src/infra/providers/datetime.provider.ts | 28 ++ src/infra/providers/event-emitter.provider.ts | 62 +++ src/infra/providers/id-generator.provider.ts | 77 ++++ src/infra/providers/index.ts | 5 + src/infra/providers/template.provider.ts | 124 ++++++ .../in-memory/in-memory.repository.ts | 178 +++++++++ src/infra/repositories/index.ts | 6 + .../mongoose/mongoose.repository.ts | 260 ++++++++++++ .../mongoose/notification.schema.ts | 88 ++++ src/infra/senders/email/nodemailer.sender.ts | 119 ++++++ src/infra/senders/index.ts | 12 + src/infra/senders/push/aws-sns-push.sender.ts | 128 ++++++ src/infra/senders/push/firebase.sender.ts | 104 +++++ src/infra/senders/push/onesignal.sender.ts | 97 +++++ src/infra/senders/sms/aws-sns.sender.ts | 119 ++++++ src/infra/senders/sms/twilio.sender.ts | 100 +++++ src/infra/senders/sms/vonage.sender.ts | 113 ++++++ tsconfig.json | 2 +- 21 files changed, 2041 insertions(+), 5 deletions(-) create mode 100644 src/infra/index.ts create mode 100644 src/infra/providers/datetime.provider.ts create mode 100644 src/infra/providers/event-emitter.provider.ts create mode 100644 src/infra/providers/id-generator.provider.ts create mode 100644 src/infra/providers/index.ts create mode 100644 src/infra/providers/template.provider.ts create mode 100644 src/infra/repositories/in-memory/in-memory.repository.ts create mode 100644 src/infra/repositories/index.ts create mode 100644 src/infra/repositories/mongoose/mongoose.repository.ts create mode 100644 src/infra/repositories/mongoose/notification.schema.ts create mode 100644 src/infra/senders/email/nodemailer.sender.ts create mode 100644 src/infra/senders/index.ts create mode 100644 src/infra/senders/push/aws-sns-push.sender.ts create mode 100644 src/infra/senders/push/firebase.sender.ts create mode 100644 src/infra/senders/push/onesignal.sender.ts create mode 100644 src/infra/senders/sms/aws-sns.sender.ts create mode 100644 src/infra/senders/sms/twilio.sender.ts create mode 100644 src/infra/senders/sms/vonage.sender.ts diff --git a/package.json b/package.json index bd323e2..8e8f357 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,32 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7" }, + "peerDependenciesMeta": { + "nodemailer": { + "optional": true + }, + "twilio": { + "optional": true + }, + "@aws-sdk/client-sns": { + "optional": true + }, + "@vonage/server-sdk": { + "optional": true + }, + "firebase-admin": { + "optional": true + }, + "mongoose": { + "optional": true + }, + "handlebars": { + "optional": true + }, + "nanoid": { + "optional": true + } + }, "dependencies": { "zod": "^3.24.1" }, diff --git a/src/infra/README.md b/src/infra/README.md index 6752dbd..db3da1f 100644 --- a/src/infra/README.md +++ b/src/infra/README.md @@ -1,5 +1,374 @@ -## Infra layer: external adapters and implementations. +# Infrastructure Layer -- May depend on `core/` -- Must not be imported by consumers directly -- Expose anything public via `src/index.ts` only +This directory contains concrete implementations of the core notification interfaces. + +## πŸ“ Structure + +``` +infra/ +β”œβ”€β”€ senders/ # Notification channel senders +β”‚ β”œβ”€β”€ email/ # Email providers +β”‚ β”œβ”€β”€ sms/ # SMS providers +β”‚ └── push/ # Push notification providers +β”œβ”€β”€ repositories/ # Data persistence +β”‚ β”œβ”€β”€ mongoose/ # MongoDB with Mongoose +β”‚ └── in-memory/ # In-memory (testing) +└── providers/ # Utility providers + β”œβ”€β”€ id-generator.provider.ts + β”œβ”€β”€ datetime.provider.ts + β”œβ”€β”€ template.provider.ts + └── event-emitter.provider.ts +``` + +## πŸ”Œ Email Senders + +### Nodemailer (SMTP) + +Works with any SMTP provider (Gmail, SendGrid, AWS SES via SMTP, etc.) + +```typescript +import { NodemailerSender } from "@ciscode/notification-kit/infra"; + +const emailSender = new NodemailerSender({ + host: "smtp.gmail.com", + port: 587, + secure: false, + auth: { + user: "your-email@gmail.com", + pass: "your-app-password", + }, + from: "noreply@example.com", + fromName: "My App", +}); +``` + +**Peer Dependency**: `nodemailer` + +## πŸ“± SMS Senders + +### Twilio + +```typescript +import { TwilioSmsSender } from "@ciscode/notification-kit/infra"; + +const smsSender = new TwilioSmsSender({ + accountSid: "your-account-sid", + authToken: "your-auth-token", + fromNumber: "+1234567890", +}); +``` + +**Peer Dependency**: `twilio` + +### AWS SNS + +```typescript +import { AwsSnsSender } from "@ciscode/notification-kit/infra"; + +const smsSender = new AwsSnsSender({ + region: "us-east-1", + accessKeyId: "your-access-key", + secretAccessKey: "your-secret-key", + senderName: "MyApp", // Optional +}); +``` + +**Peer Dependency**: `@aws-sdk/client-sns` + +### Vonage (Nexmo) + +```typescript +import { VonageSmsSender } from "@ciscode/notification-kit/infra"; + +const smsSender = new VonageSmsSender({ + apiKey: "your-api-key", + apiSecret: "your-api-secret", + from: "MyApp", +}); +``` + +**Peer Dependency**: `@vonage/server-sdk` + +## πŸ”” Push Notification Senders + +### Firebase Cloud Messaging + +```typescript +import { FirebasePushSender } from "@ciscode/notification-kit/infra"; + +const pushSender = new FirebasePushSender({ + projectId: "your-project-id", + privateKey: "your-private-key", + clientEmail: "your-client-email", +}); +``` + +**Peer Dependency**: `firebase-admin` + +### OneSignal + +```typescript +import { OneSignalPushSender } from "@ciscode/notification-kit/infra"; + +const pushSender = new OneSignalPushSender({ + appId: "your-app-id", + restApiKey: "your-rest-api-key", +}); +``` + +**No additional dependencies** (uses fetch API) + +### AWS SNS (Push) + +```typescript +import { AwsSnsPushSender } from "@ciscode/notification-kit/infra"; + +const pushSender = new AwsSnsPushSender({ + region: "us-east-1", + accessKeyId: "your-access-key", + secretAccessKey: "your-secret-key", + platformApplicationArn: "arn:aws:sns:...", +}); +``` + +**Peer Dependency**: `@aws-sdk/client-sns` + +## πŸ’Ύ Repositories + +### MongoDB with Mongoose + +```typescript +import mongoose from "mongoose"; +import { MongooseNotificationRepository } from "@ciscode/notification-kit/infra"; + +const connection = await mongoose.createConnection("mongodb://localhost:27017/mydb"); + +const repository = new MongooseNotificationRepository( + connection, + "notifications", // collection name (optional) +); +``` + +**Peer Dependency**: `mongoose` + +### In-Memory (Testing) + +```typescript +import { InMemoryNotificationRepository } from "@ciscode/notification-kit/infra"; + +const repository = new InMemoryNotificationRepository(); + +// For testing - clear all data +repository.clear(); + +// For testing - get all notifications +const all = repository.getAll(); +``` + +**No dependencies** + +## πŸ› οΈ Utility Providers + +### ID Generator + +```typescript +import { UuidGenerator, ObjectIdGenerator, NanoIdGenerator } from "@ciscode/notification-kit/infra"; + +// UUID v4 +const uuidGen = new UuidGenerator(); +uuidGen.generate(); // "a1b2c3d4-..." + +// MongoDB ObjectId format +const objectIdGen = new ObjectIdGenerator(); +objectIdGen.generate(); // "507f1f77bcf86cd799439011" + +// NanoID (requires nanoid package) +const nanoIdGen = new NanoIdGenerator(); +nanoIdGen.generate(); // "V1StGXR8_Z5jdHi6B-myT" +``` + +### DateTime Provider + +```typescript +import { DateTimeProvider } from "@ciscode/notification-kit/infra"; + +const dateTime = new DateTimeProvider(); + +dateTime.now(); // "2024-01-15T10:30:00.000Z" +dateTime.isPast("2024-01-01T00:00:00.000Z"); // true +dateTime.isFuture("2025-01-01T00:00:00.000Z"); // true +``` + +### Template Engine + +#### Handlebars + +```typescript +import { HandlebarsTemplateEngine } from "@ciscode/notification-kit/infra"; + +const templateEngine = new HandlebarsTemplateEngine({ + templates: { + welcome: { + title: "Welcome {{name}}!", + body: "Hello {{name}}, thanks for joining!", + html: "

Welcome {{name}}!

", + }, + }, +}); + +const result = await templateEngine.render("welcome", { name: "John" }); +// { title: 'Welcome John!', body: 'Hello John, thanks for joining!', html: '

Welcome John!

' } +``` + +**Peer Dependency**: `handlebars` + +#### Simple Template Engine + +```typescript +import { SimpleTemplateEngine } from "@ciscode/notification-kit/infra"; + +const templateEngine = new SimpleTemplateEngine({ + welcome: { + title: "Welcome {{name}}!", + body: "Hello {{name}}, thanks for joining!", + }, +}); + +const result = await templateEngine.render("welcome", { name: "John" }); +// Uses simple {{variable}} replacement +``` + +**No dependencies** + +### Event Emitter + +#### In-Memory Event Emitter + +```typescript +import { InMemoryEventEmitter } from "@ciscode/notification-kit/infra"; + +const eventEmitter = new InMemoryEventEmitter(); + +// Listen to specific events +eventEmitter.on("notification.sent", (event) => { + console.log("Notification sent:", event.notification.id); +}); + +// Listen to all events +eventEmitter.on("*", (event) => { + console.log("Event:", event.type); +}); +``` + +#### Console Event Emitter + +```typescript +import { ConsoleEventEmitter } from "@ciscode/notification-kit/infra"; + +const eventEmitter = new ConsoleEventEmitter(); +// Logs all events to console +``` + +## πŸ“¦ Installation + +Install only the peer dependencies you need: + +### Email (Nodemailer) + +```bash +npm install nodemailer +npm install -D @types/nodemailer +``` + +### SMS + +```bash +# Twilio +npm install twilio + +# AWS SNS +npm install @aws-sdk/client-sns + +# Vonage +npm install @vonage/server-sdk +``` + +### Push Notifications + +```bash +# Firebase +npm install firebase-admin + +# AWS SNS (same as SMS) +npm install @aws-sdk/client-sns +``` + +### Repository + +```bash +# Mongoose +npm install mongoose +``` + +### Template Engine + +```bash +# Handlebars +npm install handlebars +npm install -D @types/handlebars +``` + +### ID Generator + +```bash +# NanoID (optional) +npm install nanoid +``` + +## 🎯 Usage with NestJS Module + +These implementations will be used when configuring the NotificationKit module: + +```typescript +import { Module } from "@nestjs/common"; +import { NotificationKitModule } from "@ciscode/notification-kit"; +import { + NodemailerSender, + TwilioSmsSender, + FirebasePushSender, + MongooseNotificationRepository, + UuidGenerator, + DateTimeProvider, + InMemoryEventEmitter, +} from "@ciscode/notification-kit/infra"; + +@Module({ + imports: [ + NotificationKitModule.register({ + senders: [ + new NodemailerSender({ + /* config */ + }), + new TwilioSmsSender({ + /* config */ + }), + new FirebasePushSender({ + /* config */ + }), + ], + repository: new MongooseNotificationRepository(/* mongoose connection */), + idGenerator: new UuidGenerator(), + dateTimeProvider: new DateTimeProvider(), + eventEmitter: new InMemoryEventEmitter(), + }), + ], +}) +export class AppModule {} +``` + +## πŸ”’ Architecture Notes + +- All implementations use **lazy loading** for peer dependencies +- External packages are imported dynamically to avoid build-time dependencies +- TypeScript errors for missing packages are suppressed with `@ts-expect-error` +- Only install the peer dependencies you actually use diff --git a/src/infra/index.ts b/src/infra/index.ts new file mode 100644 index 0000000..a00d201 --- /dev/null +++ b/src/infra/index.ts @@ -0,0 +1,21 @@ +/** + * Infrastructure Layer + * + * This layer contains concrete implementations of the core interfaces. + * It includes: + * - Notification senders (email, SMS, push) + * - Repositories (MongoDB, in-memory) + * - Utility providers (ID generator, datetime, templates, events) + * + * These implementations are internal and not exported by default. + * They can be used when configuring the NestJS module. + */ + +// Senders +export * from "./senders"; + +// Repositories +export * from "./repositories"; + +// Providers +export * from "./providers"; diff --git a/src/infra/providers/datetime.provider.ts b/src/infra/providers/datetime.provider.ts new file mode 100644 index 0000000..f0ca7fb --- /dev/null +++ b/src/infra/providers/datetime.provider.ts @@ -0,0 +1,28 @@ +import type { IDateTimeProvider } from "../../core"; + +/** + * DateTime provider implementation using native Date + */ +export class DateTimeProvider implements IDateTimeProvider { + now(): string { + return new Date().toISOString(); + } + + isPast(_datetime: string): boolean { + try { + const date = new Date(_datetime); + return date.getTime() < Date.now(); + } catch { + return false; + } + } + + isFuture(_datetime: string): boolean { + try { + const date = new Date(_datetime); + return date.getTime() > Date.now(); + } catch { + return false; + } + } +} diff --git a/src/infra/providers/event-emitter.provider.ts b/src/infra/providers/event-emitter.provider.ts new file mode 100644 index 0000000..c70a908 --- /dev/null +++ b/src/infra/providers/event-emitter.provider.ts @@ -0,0 +1,62 @@ +import type { INotificationEventEmitter, NotificationEvent } from "../../core"; + +export type NotificationEventHandler = (event: NotificationEvent) => void | Promise; + +/** + * Simple in-memory event emitter implementation + */ +export class InMemoryEventEmitter implements INotificationEventEmitter { + private handlers: Map = new Map(); + + async emit(_event: NotificationEvent): Promise { + const handlers = this.handlers.get(_event.type) || []; + const allHandlers = this.handlers.get("*") || []; + + const allPromises = [...handlers, ...allHandlers].map((handler) => { + try { + return Promise.resolve(handler(_event)); + } catch (error) { + console.error(`Error in event handler for ${_event.type}:`, error); + return Promise.resolve(); + } + }); + + await Promise.all(allPromises); + } + + /** + * Register an event handler + */ + on(eventType: NotificationEvent["type"] | "*", handler: NotificationEventHandler): void { + const handlers = this.handlers.get(eventType) || []; + handlers.push(handler); + this.handlers.set(eventType, handlers); + } + + /** + * Unregister an event handler + */ + off(eventType: NotificationEvent["type"] | "*", handler: NotificationEventHandler): void { + const handlers = this.handlers.get(eventType) || []; + const index = handlers.indexOf(handler); + if (index > -1) { + handlers.splice(index, 1); + } + } + + /** + * Clear all handlers + */ + clear(): void { + this.handlers.clear(); + } +} + +/** + * Event emitter that logs events to console + */ +export class ConsoleEventEmitter implements INotificationEventEmitter { + async emit(_event: NotificationEvent): Promise { + console.log(`[NotificationEvent] ${_event.type}`, _event); + } +} diff --git a/src/infra/providers/id-generator.provider.ts b/src/infra/providers/id-generator.provider.ts new file mode 100644 index 0000000..f666614 --- /dev/null +++ b/src/infra/providers/id-generator.provider.ts @@ -0,0 +1,77 @@ +import type { IIdGenerator } from "../../core"; + +/** + * ID generator using UUID v4 + */ +export class UuidGenerator implements IIdGenerator { + generate(): string { + // Simple UUID v4 implementation + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + } +} + +/** + * ID generator using MongoDB ObjectId format + */ +export class ObjectIdGenerator implements IIdGenerator { + private counter = Math.floor(Math.random() * 0xffffff); + + generate(): string { + // Generate MongoDB ObjectId-like string (24 hex characters) + const timestamp = Math.floor(Date.now() / 1000) + .toString(16) + .padStart(8, "0"); + const machineId = Math.floor(Math.random() * 0xffffff) + .toString(16) + .padStart(6, "0"); + const processId = Math.floor(Math.random() * 0xffff) + .toString(16) + .padStart(4, "0"); + this.counter = (this.counter + 1) % 0xffffff; + const counter = this.counter.toString(16).padStart(6, "0"); + + return timestamp + machineId + processId + counter; + } +} + +/** + * ID generator using NanoID (requires nanoid package) + * Note: Returns synchronous string, loads nanoid on first use + */ +export class NanoIdGenerator implements IIdGenerator { + private nanoid: (() => string) | null = null; + private initialized = false; + + generate(): string { + if (!this.initialized) { + // For first call, use UUID fallback and initialize in background + this.initialize(); + return new UuidGenerator().generate(); + } + + if (!this.nanoid) { + return new UuidGenerator().generate(); + } + + return this.nanoid(); + } + + private async initialize(): Promise { + if (this.initialized) return; + + try { + // @ts-expect-error - nanoid is an optional peer dependency + const { nanoid } = await import("nanoid"); + this.nanoid = nanoid; + } catch { + // Fallback to UUID if nanoid is not installed + this.nanoid = () => new UuidGenerator().generate(); + } + + this.initialized = true; + } +} diff --git a/src/infra/providers/index.ts b/src/infra/providers/index.ts new file mode 100644 index 0000000..c1c4975 --- /dev/null +++ b/src/infra/providers/index.ts @@ -0,0 +1,5 @@ +// Utility providers +export * from "./id-generator.provider"; +export * from "./datetime.provider"; +export * from "./template.provider"; +export * from "./event-emitter.provider"; diff --git a/src/infra/providers/template.provider.ts b/src/infra/providers/template.provider.ts new file mode 100644 index 0000000..1c50fab --- /dev/null +++ b/src/infra/providers/template.provider.ts @@ -0,0 +1,124 @@ +import type { ITemplateEngine, TemplateResult } from "../../core"; + +export interface HandlebarsTemplateConfig { + templates: Record; +} + +/** + * Template engine implementation using Handlebars + */ +export class HandlebarsTemplateEngine implements ITemplateEngine { + private handlebars: any = null; + private compiledTemplates: Map = new Map(); + + constructor(private readonly config: HandlebarsTemplateConfig) {} + + /** + * Initialize Handlebars lazily + */ + private async getHandlebars(): Promise { + if (this.handlebars) { + return this.handlebars; + } + + const Handlebars = await import("handlebars"); + this.handlebars = Handlebars.default || Handlebars; + + return this.handlebars; + } + + async render(_templateId: string, _variables: Record): Promise { + const template = this.config.templates[_templateId]; + + if (!template) { + throw new Error(`Template ${_templateId} not found`); + } + + const handlebars = await this.getHandlebars(); + + // Compile and cache templates + if (!this.compiledTemplates.has(_templateId)) { + const compiled = { + title: handlebars.compile(template.title), + body: handlebars.compile(template.body), + html: template.html ? handlebars.compile(template.html) : undefined, + }; + this.compiledTemplates.set(_templateId, compiled); + } + + const compiled = this.compiledTemplates.get(_templateId)!; + + return { + title: compiled.title(_variables), + body: compiled.body(_variables), + html: compiled.html ? compiled.html(_variables) : undefined, + }; + } + + async hasTemplate(_templateId: string): Promise { + return !!this.config.templates[_templateId]; + } + + async validateVariables( + _templateId: string, + _variables: Record, + ): Promise { + try { + await this.render(_templateId, _variables); + return true; + } catch { + return false; + } + } +} + +/** + * Simple template engine using string replacement + */ +export class SimpleTemplateEngine implements ITemplateEngine { + constructor( + private readonly templates: Record, + ) {} + + async render(_templateId: string, _variables: Record): Promise { + const template = this.templates[_templateId]; + + if (!template) { + throw new Error(`Template ${_templateId} not found`); + } + + const result: TemplateResult = { + title: this.replaceVariables(template.title, _variables), + body: this.replaceVariables(template.body, _variables), + }; + + if (template.html) { + result.html = this.replaceVariables(template.html, _variables); + } + + return result; + } + + async hasTemplate(_templateId: string): Promise { + return !!this.templates[_templateId]; + } + + async validateVariables( + _templateId: string, + _variables: Record, + ): Promise { + try { + await this.render(_templateId, _variables); + return true; + } catch { + return false; + } + } + + private replaceVariables(template: string, variables: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => { + const value = variables[key]; + return value !== undefined ? String(value) : ""; + }); + } +} diff --git a/src/infra/repositories/in-memory/in-memory.repository.ts b/src/infra/repositories/in-memory/in-memory.repository.ts new file mode 100644 index 0000000..c98edcf --- /dev/null +++ b/src/infra/repositories/in-memory/in-memory.repository.ts @@ -0,0 +1,178 @@ +import type { + INotificationRepository, + Notification, + NotificationQueryCriteria, +} from "../../../core"; + +/** + * In-memory repository implementation for testing/simple cases + */ +export class InMemoryNotificationRepository implements INotificationRepository { + private notifications: Map = new Map(); + private idCounter = 1; + + async create( + _notification: Omit, + ): Promise { + const now = new Date().toISOString(); + const id = `notif_${this.idCounter++}`; + + const notification: Notification = { + id, + ..._notification, + createdAt: now, + updatedAt: now, + }; + + this.notifications.set(id, notification); + + return notification; + } + + async findById(_id: string): Promise { + return this.notifications.get(_id) || null; + } + + async find(_criteria: NotificationQueryCriteria): Promise { + let results = Array.from(this.notifications.values()); + + // Apply filters + if (_criteria.recipientId) { + results = results.filter((n) => n.recipient.id === _criteria.recipientId); + } + + if (_criteria.channel) { + results = results.filter((n) => n.channel === _criteria.channel); + } + + if (_criteria.status) { + results = results.filter((n) => n.status === _criteria.status); + } + + if (_criteria.priority) { + results = results.filter((n) => n.priority === _criteria.priority); + } + + if (_criteria.fromDate) { + results = results.filter((n) => n.createdAt >= _criteria.fromDate!); + } + + if (_criteria.toDate) { + results = results.filter((n) => n.createdAt <= _criteria.toDate!); + } + + // Sort by createdAt descending + results.sort((a, b) => (b.createdAt > a.createdAt ? 1 : -1)); + + // Apply pagination + const offset = _criteria.offset || 0; + const limit = _criteria.limit || 10; + + return results.slice(offset, offset + limit); + } + + async update(_id: string, _updates: Partial): Promise { + const notification = this.notifications.get(_id); + + if (!notification) { + throw new Error(`Notification with id ${_id} not found`); + } + + const updated: Notification = { + ...notification, + ..._updates, + id: notification.id, // Preserve ID + createdAt: notification.createdAt, // Preserve createdAt + updatedAt: new Date().toISOString(), + }; + + this.notifications.set(_id, updated); + + return updated; + } + + async delete(_id: string): Promise { + return this.notifications.delete(_id); + } + + async count(_criteria: NotificationQueryCriteria): Promise { + let results = Array.from(this.notifications.values()); + + // Apply filters + if (_criteria.recipientId) { + results = results.filter((n) => n.recipient.id === _criteria.recipientId); + } + + if (_criteria.channel) { + results = results.filter((n) => n.channel === _criteria.channel); + } + + if (_criteria.status) { + results = results.filter((n) => n.status === _criteria.status); + } + + if (_criteria.priority) { + results = results.filter((n) => n.priority === _criteria.priority); + } + + if (_criteria.fromDate) { + results = results.filter((n) => n.createdAt >= _criteria.fromDate!); + } + + if (_criteria.toDate) { + results = results.filter((n) => n.createdAt <= _criteria.toDate!); + } + + return results.length; + } + + async findReadyToSend(_limit: number): Promise { + const now = new Date().toISOString(); + let results = Array.from(this.notifications.values()); + + // Find notifications ready to send + results = results.filter((n) => { + // Pending notifications that are scheduled and ready + if (n.status === "pending" && n.scheduledFor && n.scheduledFor <= now) { + return true; + } + + // Queued notifications (ready to send immediately) + if (n.status === "queued") { + return true; + } + + // Failed notifications that haven't exceeded retry count + if (n.status === "failed" && n.retryCount < n.maxRetries) { + return true; + } + + return false; + }); + + // Sort by priority (high to low) then by createdAt (oldest first) + const priorityOrder: Record = { urgent: 4, high: 3, normal: 2, low: 1 }; + results.sort((a, b) => { + const priorityDiff = (priorityOrder[b.priority] || 0) - (priorityOrder[a.priority] || 0); + if (priorityDiff !== 0) return priorityDiff; + return a.createdAt > b.createdAt ? 1 : -1; + }); + + return results.slice(0, _limit); + } + + /** + * Clear all notifications (for testing) + */ + clear(): void { + this.notifications.clear(); + this.idCounter = 1; + } + + /** + * Get all notifications (for testing) + */ + getAll(): Notification[] { + return Array.from(this.notifications.values()); + } +} diff --git a/src/infra/repositories/index.ts b/src/infra/repositories/index.ts new file mode 100644 index 0000000..fab52b3 --- /dev/null +++ b/src/infra/repositories/index.ts @@ -0,0 +1,6 @@ +// MongoDB/Mongoose repository +export * from "./mongoose/notification.schema"; +export * from "./mongoose/mongoose.repository"; + +// In-memory repository +export * from "./in-memory/in-memory.repository"; diff --git a/src/infra/repositories/mongoose/mongoose.repository.ts b/src/infra/repositories/mongoose/mongoose.repository.ts new file mode 100644 index 0000000..7f47825 --- /dev/null +++ b/src/infra/repositories/mongoose/mongoose.repository.ts @@ -0,0 +1,260 @@ +import type { Model, Connection } from "mongoose"; + +import type { + INotificationRepository, + Notification, + NotificationQueryCriteria, +} from "../../../core"; + +import type { CreateNotificationInput, NotificationDocument } from "./notification.schema"; +import { notificationSchemaDefinition } from "./notification.schema"; + +/** + * MongoDB repository implementation using Mongoose + */ +export class MongooseNotificationRepository implements INotificationRepository { + private model: Model | null = null; + + constructor( + private readonly connection: Connection, + private readonly collectionName: string = "notifications", + ) {} + + /** + * Get or create the Mongoose model + */ + private getModel(): Model { + if (this.model) { + return this.model; + } + + const mongoose = (this.connection as any).base; + const schema = new mongoose.Schema(notificationSchemaDefinition, { + collection: this.collectionName, + timestamps: false, // We handle timestamps manually + }); + + // Add indexes + schema.index({ "recipient.id": 1, createdAt: -1 }); + schema.index({ status: 1, scheduledFor: 1 }); + schema.index({ channel: 1, createdAt: -1 }); + schema.index({ createdAt: -1 }); + + this.model = this.connection.model( + "Notification", + schema, + this.collectionName, + ); + + return this.model; + } + + async create( + _notification: Omit, + ): Promise { + const Model = this.getModel(); + + const now = new Date().toISOString(); + const doc = await Model.create({ + ..._notification, + createdAt: now, + updatedAt: now, + } as CreateNotificationInput); + + return this.documentToNotification(doc); + } + + async findById(_id: string): Promise { + const Model = this.getModel(); + const doc = await Model.findById(_id).exec(); + + if (!doc) { + return null; + } + + return this.documentToNotification(doc); + } + + async find(_criteria: NotificationQueryCriteria): Promise { + const Model = this.getModel(); + + const filter: any = {}; + + if (_criteria.recipientId) { + filter["recipient.id"] = _criteria.recipientId; + } + + if (_criteria.channel) { + filter.channel = _criteria.channel; + } + + if (_criteria.status) { + filter.status = _criteria.status; + } + + if (_criteria.priority) { + filter.priority = _criteria.priority; + } + + if (_criteria.fromDate || _criteria.toDate) { + filter.createdAt = {}; + if (_criteria.fromDate) { + filter.createdAt.$gte = _criteria.fromDate; + } + if (_criteria.toDate) { + filter.createdAt.$lte = _criteria.toDate; + } + } + + const query = Model.find(filter).sort({ createdAt: -1 }); + + if (_criteria.limit) { + query.limit(_criteria.limit); + } + + if (_criteria.offset) { + query.skip(_criteria.offset); + } + + const docs = await query.exec(); + + return docs.map((doc) => this.documentToNotification(doc)); + } + + async update(_id: string, _updates: Partial): Promise { + const Model = this.getModel(); + + const updateData: any = { ..._updates }; + updateData.updatedAt = new Date().toISOString(); + + // Remove id and timestamps from updates if present + delete updateData.id; + delete updateData.createdAt; + + const doc = await Model.findByIdAndUpdate(_id, updateData, { new: true }).exec(); + + if (!doc) { + throw new Error(`Notification with id ${_id} not found`); + } + + return this.documentToNotification(doc); + } + + async delete(_id: string): Promise { + const Model = this.getModel(); + const result = await Model.findByIdAndDelete(_id).exec(); + return !!result; + } + + async count(_criteria: NotificationQueryCriteria): Promise { + const Model = this.getModel(); + + const filter: any = {}; + + if (_criteria.recipientId) { + filter["recipient.id"] = _criteria.recipientId; + } + + if (_criteria.channel) { + filter.channel = _criteria.channel; + } + + if (_criteria.status) { + filter.status = _criteria.status; + } + + if (_criteria.priority) { + filter.priority = _criteria.priority; + } + + if (_criteria.fromDate || _criteria.toDate) { + filter.createdAt = {}; + if (_criteria.fromDate) { + filter.createdAt.$gte = _criteria.fromDate; + } + if (_criteria.toDate) { + filter.createdAt.$lte = _criteria.toDate; + } + } + + return Model.countDocuments(filter).exec(); + } + + async findReadyToSend(_limit: number): Promise { + const Model = this.getModel(); + + const now = new Date().toISOString(); + + const docs = await Model.find({ + $or: [ + // Pending notifications that are scheduled and ready + { + status: "pending", + scheduledFor: { $lte: now }, + }, + // Queued notifications (ready to send immediately) + { + status: "queued", + }, + // Failed notifications that haven't exceeded retry count + { + status: "failed", + $expr: { $lt: ["$retryCount", "$maxRetries"] }, + }, + ], + }) + .sort({ priority: -1, createdAt: 1 }) // High priority first, then oldest + .limit(_limit) + .exec(); + + return docs.map((doc) => this.documentToNotification(doc)); + } + + /** + * Convert Mongoose document to Notification entity + */ + private documentToNotification(doc: NotificationDocument): Notification { + return { + id: doc._id.toString(), + channel: doc.channel, + status: doc.status, + priority: doc.priority, + recipient: { + id: doc.recipient.id, + email: doc.recipient.email, + phone: doc.recipient.phone, + deviceToken: doc.recipient.deviceToken, + metadata: doc.recipient.metadata ? this.mapToRecord(doc.recipient.metadata) : undefined, + }, + content: { + title: doc.content.title, + body: doc.content.body, + html: doc.content.html, + data: doc.content.data ? this.mapToRecord(doc.content.data) : undefined, + templateId: doc.content.templateId, + templateVars: doc.content.templateVars + ? this.mapToRecord(doc.content.templateVars) + : undefined, + }, + scheduledFor: doc.scheduledFor, + sentAt: doc.sentAt, + deliveredAt: doc.deliveredAt, + error: doc.error, + retryCount: doc.retryCount, + maxRetries: doc.maxRetries, + metadata: doc.metadata ? this.mapToRecord(doc.metadata) : undefined, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; + } + + /** + * Convert Mongoose Map to plain object + */ + private mapToRecord(map: Map | any): Record { + if (map instanceof Map) { + return Object.fromEntries(map); + } + return map; + } +} diff --git a/src/infra/repositories/mongoose/notification.schema.ts b/src/infra/repositories/mongoose/notification.schema.ts new file mode 100644 index 0000000..0efa32f --- /dev/null +++ b/src/infra/repositories/mongoose/notification.schema.ts @@ -0,0 +1,88 @@ +import type { + Notification, + NotificationChannel, + NotificationContent, + NotificationPriority, + NotificationRecipient, + NotificationStatus, +} from "../../../core"; + +// Helper to get Schema type at runtime (for Mongoose schema definitions) +const getSchemaTypes = () => { + try { + // @ts-expect-error - mongoose is an optional peer dependency + const mongoose = require("mongoose"); + return mongoose.Schema.Types; + } catch { + return { Mixed: {} }; + } +}; + +const SchemaTypes = getSchemaTypes(); + +/** + * Mongoose schema definition for Notification + */ +export interface NotificationDocument extends Omit { + _id: string; +} + +export const notificationSchemaDefinition = { + channel: { + type: String, + required: true, + enum: ["email", "sms", "push", "in_app", "webhook"], + }, + status: { + type: String, + required: true, + enum: ["pending", "queued", "sending", "sent", "delivered", "failed", "cancelled"], + }, + priority: { + type: String, + required: true, + enum: ["low", "normal", "high", "urgent"], + }, + recipient: { + id: { type: String, required: true }, + email: { type: String }, + phone: { type: String }, + deviceToken: { type: String }, + metadata: { type: Map, of: SchemaTypes.Mixed }, + }, + content: { + title: { type: String, required: true }, + body: { type: String, required: true }, + html: { type: String }, + data: { type: Map, of: SchemaTypes.Mixed }, + templateId: { type: String }, + templateVars: { type: Map, of: SchemaTypes.Mixed }, + }, + scheduledFor: { type: String }, + sentAt: { type: String }, + deliveredAt: { type: String }, + error: { type: String }, + retryCount: { type: Number, required: true, default: 0 }, + maxRetries: { type: Number, required: true, default: 3 }, + metadata: { type: Map, of: SchemaTypes.Mixed }, + createdAt: { type: String, required: true }, + updatedAt: { type: String, required: true }, +}; + +/** + * Type helper for creating a new notification + */ +export type CreateNotificationInput = { + channel: NotificationChannel; + status: NotificationStatus; + priority: NotificationPriority; + recipient: NotificationRecipient; + content: NotificationContent; + scheduledFor?: string | undefined; + sentAt?: string | undefined; + deliveredAt?: string | undefined; + error?: string | undefined; + retryCount: number; + maxRetries: number; + metadata?: Record | undefined; +}; diff --git a/src/infra/senders/email/nodemailer.sender.ts b/src/infra/senders/email/nodemailer.sender.ts new file mode 100644 index 0000000..6855b63 --- /dev/null +++ b/src/infra/senders/email/nodemailer.sender.ts @@ -0,0 +1,119 @@ +import type { + INotificationSender, + NotificationChannel, + NotificationContent, + NotificationRecipient, + NotificationResult, +} from "../../../core"; + +export interface NodemailerConfig { + host: string; + port: number; + secure?: boolean | undefined; + auth?: + | { + user: string; + pass: string; + } + | undefined; + from: string; + fromName?: string | undefined; +} + +/** + * Email sender implementation using Nodemailer + * Supports any SMTP provider (Gmail, SendGrid, AWS SES, etc.) + */ +export class NodemailerSender implements INotificationSender { + readonly channel: NotificationChannel = "email" as NotificationChannel; + private transporter: any = null; + + constructor(private readonly config: NodemailerConfig) {} + + /** + * Initialize the nodemailer transporter lazily + */ + private async getTransporter(): Promise { + if (this.transporter) { + return this.transporter; + } + + // Dynamic import to avoid requiring nodemailer at build time + // @ts-expect-error - nodemailer is an optional peer dependency + const nodemailer = await import("nodemailer"); + + this.transporter = nodemailer.createTransport({ + host: this.config.host, + port: this.config.port, + secure: this.config.secure ?? false, + auth: this.config.auth, + }); + + return this.transporter; + } + + async send( + _recipient: NotificationRecipient, + _content: NotificationContent, + ): Promise { + try { + if (!_recipient.email) { + return { + success: false, + notificationId: "", + error: "Recipient email is required", + }; + } + + const transporter = await this.getTransporter(); + + const mailOptions = { + from: this.config.fromName + ? `"${this.config.fromName}" <${this.config.from}>` + : this.config.from, + to: _recipient.email, + subject: _content.title, + text: _content.body, + html: _content.html, + }; + + const info = await transporter.sendMail(mailOptions); + + return { + success: true, + notificationId: _recipient.id, + providerMessageId: info.messageId, + metadata: { + accepted: info.accepted, + rejected: info.rejected, + response: info.response, + }, + }; + } catch (error) { + return { + success: false, + notificationId: _recipient.id, + error: error instanceof Error ? error.message : "Failed to send email", + }; + } + } + + async isReady(): Promise { + try { + const transporter = await this.getTransporter(); + await transporter.verify(); + return true; + } catch { + return false; + } + } + + validateRecipient(_recipient: NotificationRecipient): boolean { + return !!_recipient.email && this.isValidEmail(_recipient.email); + } + + private isValidEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + } +} diff --git a/src/infra/senders/index.ts b/src/infra/senders/index.ts new file mode 100644 index 0000000..77ceae4 --- /dev/null +++ b/src/infra/senders/index.ts @@ -0,0 +1,12 @@ +// Email senders +export * from "./email/nodemailer.sender"; + +// SMS senders +export * from "./sms/twilio.sender"; +export * from "./sms/aws-sns.sender"; +export * from "./sms/vonage.sender"; + +// Push notification senders +export * from "./push/firebase.sender"; +export * from "./push/onesignal.sender"; +export * from "./push/aws-sns-push.sender"; diff --git a/src/infra/senders/push/aws-sns-push.sender.ts b/src/infra/senders/push/aws-sns-push.sender.ts new file mode 100644 index 0000000..55ed0a5 --- /dev/null +++ b/src/infra/senders/push/aws-sns-push.sender.ts @@ -0,0 +1,128 @@ +import type { + INotificationSender, + NotificationChannel, + NotificationContent, + NotificationRecipient, + NotificationResult, +} from "../../../core"; + +export interface AwsSnsPushConfig { + region: string; + accessKeyId: string; + secretAccessKey: string; + platformApplicationArn: string; +} + +/** + * Push notification sender implementation using AWS SNS + */ +export class AwsSnsPushSender implements INotificationSender { + readonly channel: NotificationChannel = "push" as NotificationChannel; + private sns: any = null; + + constructor(private readonly config: AwsSnsPushConfig) {} + + /** + * Initialize AWS SNS client lazily + */ + private async getClient(): Promise { + if (this.sns) { + return this.sns; + } + + // Dynamic import to avoid requiring @aws-sdk at build time + // @ts-expect-error - @aws-sdk/client-sns is an optional peer dependency + const { SNSClient, PublishCommand } = await import("@aws-sdk/client-sns"); + + this.sns = { + client: new SNSClient({ + region: this.config.region, + credentials: { + accessKeyId: this.config.accessKeyId, + secretAccessKey: this.config.secretAccessKey, + }, + }), + PublishCommand, + }; + + return this.sns; + } + + async send( + _recipient: NotificationRecipient, + _content: NotificationContent, + ): Promise { + try { + if (!_recipient.deviceToken) { + return { + success: false, + notificationId: _recipient.id, + error: "Recipient device token (endpoint ARN) is required", + }; + } + + const { client, PublishCommand } = await this.getClient(); + + // For AWS SNS push, the message format depends on the platform + const message = JSON.stringify({ + default: _content.body, + GCM: JSON.stringify({ + notification: { + title: _content.title, + body: _content.body, + }, + data: _content.data, + }), + APNS: JSON.stringify({ + aps: { + alert: { + title: _content.title, + body: _content.body, + }, + }, + data: _content.data, + }), + }); + + const params = { + Message: message, + MessageStructure: "json", + TargetArn: _recipient.deviceToken, // This should be the endpoint ARN + }; + + const command = new PublishCommand(params); + const response = await client.send(command); + + return { + success: true, + notificationId: _recipient.id, + providerMessageId: response.MessageId, + }; + } catch (error) { + return { + success: false, + notificationId: _recipient.id, + error: + error instanceof Error ? error.message : "Failed to send push notification via AWS SNS", + }; + } + } + + async isReady(): Promise { + try { + const { client } = await this.getClient(); + return !!client; + } catch { + return false; + } + } + + validateRecipient(_recipient: NotificationRecipient): boolean { + // For AWS SNS, deviceToken should be an endpoint ARN + return ( + !!_recipient.deviceToken && + _recipient.deviceToken.startsWith("arn:aws:sns:") && + _recipient.deviceToken.includes(":endpoint/") + ); + } +} diff --git a/src/infra/senders/push/firebase.sender.ts b/src/infra/senders/push/firebase.sender.ts new file mode 100644 index 0000000..18b5a08 --- /dev/null +++ b/src/infra/senders/push/firebase.sender.ts @@ -0,0 +1,104 @@ +import type { + INotificationSender, + NotificationChannel, + NotificationContent, + NotificationRecipient, + NotificationResult, +} from "../../../core"; + +export interface FirebaseConfig { + projectId: string; + privateKey: string; + clientEmail: string; +} + +/** + * Push notification sender implementation using Firebase Cloud Messaging (FCM) + */ +export class FirebasePushSender implements INotificationSender { + readonly channel: NotificationChannel = "push" as NotificationChannel; + private app: any = null; + private messaging: any = null; + + constructor(private readonly config: FirebaseConfig) {} + + /** + * Initialize Firebase app lazily + */ + private async getMessaging(): Promise { + if (this.messaging) { + return this.messaging; + } + + // Dynamic import to avoid requiring firebase-admin at build time + // @ts-expect-error - firebase-admin is an optional peer dependency + const admin = await import("firebase-admin"); + + if (!this.app) { + this.app = admin.initializeApp({ + credential: admin.credential.cert({ + projectId: this.config.projectId, + privateKey: this.config.privateKey.replace(/\\n/g, "\n"), + clientEmail: this.config.clientEmail, + }), + }); + } + + this.messaging = admin.messaging(this.app); + + return this.messaging; + } + + async send( + _recipient: NotificationRecipient, + _content: NotificationContent, + ): Promise { + try { + if (!_recipient.deviceToken) { + return { + success: false, + notificationId: _recipient.id, + error: "Recipient device token is required", + }; + } + + const messaging = await this.getMessaging(); + + const message = { + token: _recipient.deviceToken, + notification: { + title: _content.title, + body: _content.body, + }, + data: _content.data as Record | undefined, + }; + + const messageId = await messaging.send(message); + + return { + success: true, + notificationId: _recipient.id, + providerMessageId: messageId, + }; + } catch (error) { + return { + success: false, + notificationId: _recipient.id, + error: error instanceof Error ? error.message : "Failed to send push notification via FCM", + }; + } + } + + async isReady(): Promise { + try { + const messaging = await this.getMessaging(); + return !!messaging; + } catch { + return false; + } + } + + validateRecipient(_recipient: NotificationRecipient): boolean { + return !!_recipient.deviceToken && _recipient.deviceToken.length > 0; + } +} diff --git a/src/infra/senders/push/onesignal.sender.ts b/src/infra/senders/push/onesignal.sender.ts new file mode 100644 index 0000000..85a0845 --- /dev/null +++ b/src/infra/senders/push/onesignal.sender.ts @@ -0,0 +1,97 @@ +import type { + INotificationSender, + NotificationChannel, + NotificationContent, + NotificationRecipient, + NotificationResult, +} from "../../../core"; + +export interface OneSignalConfig { + appId: string; + restApiKey: string; +} + +/** + * Push notification sender implementation using OneSignal + */ +export class OneSignalPushSender implements INotificationSender { + readonly channel: NotificationChannel = "push" as NotificationChannel; + private readonly baseUrl = "https://onesignal.com/api/v1"; + + constructor(private readonly config: OneSignalConfig) {} + + async send( + _recipient: NotificationRecipient, + _content: NotificationContent, + ): Promise { + try { + if (!_recipient.deviceToken) { + return { + success: false, + notificationId: _recipient.id, + error: "Recipient device token is required", + }; + } + + const response = await fetch(`${this.baseUrl}/notifications`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${this.config.restApiKey}`, + }, + body: JSON.stringify({ + app_id: this.config.appId, + include_player_ids: [_recipient.deviceToken], + headings: { en: _content.title }, + contents: { en: _content.body }, + data: _content.data, + }), + }); + + const result = await response.json(); + + if (response.ok && result.id) { + return { + success: true, + notificationId: _recipient.id, + providerMessageId: result.id, + metadata: { + recipients: result.recipients, + }, + }; + } else { + return { + success: false, + notificationId: _recipient.id, + error: result.errors?.[0] || "Failed to send push notification via OneSignal", + }; + } + } catch (error) { + return { + success: false, + notificationId: _recipient.id, + error: + error instanceof Error ? error.message : "Failed to send push notification via OneSignal", + }; + } + } + + async isReady(): Promise { + try { + // Verify API key by fetching app info + const response = await fetch(`${this.baseUrl}/apps/${this.config.appId}`, { + headers: { + Authorization: `Basic ${this.config.restApiKey}`, + }, + }); + + return response.ok; + } catch { + return false; + } + } + + validateRecipient(_recipient: NotificationRecipient): boolean { + return !!_recipient.deviceToken && _recipient.deviceToken.length > 0; + } +} diff --git a/src/infra/senders/sms/aws-sns.sender.ts b/src/infra/senders/sms/aws-sns.sender.ts new file mode 100644 index 0000000..12685a9 --- /dev/null +++ b/src/infra/senders/sms/aws-sns.sender.ts @@ -0,0 +1,119 @@ +import type { + INotificationSender, + NotificationChannel, + NotificationContent, + NotificationRecipient, + NotificationResult, +} from "../../../core"; + +export interface AwsSnsConfig { + region: string; + accessKeyId: string; + secretAccessKey: string; + senderName?: string; +} + +/** + * SMS sender implementation using AWS SNS + */ +export class AwsSnsSender implements INotificationSender { + readonly channel: NotificationChannel = "sms" as NotificationChannel; + private sns: any = null; + + constructor(private readonly config: AwsSnsConfig) {} + + /** + * Initialize AWS SNS client lazily + */ + private async getClient(): Promise { + if (this.sns) { + return this.sns; + } + + // Dynamic import to avoid requiring @aws-sdk at build time + // @ts-expect-error - @aws-sdk/client-sns is an optional peer dependency + const { SNSClient, PublishCommand } = await import("@aws-sdk/client-sns"); + + this.sns = { + client: new SNSClient({ + region: this.config.region, + credentials: { + accessKeyId: this.config.accessKeyId, + secretAccessKey: this.config.secretAccessKey, + }, + }), + PublishCommand, + }; + + return this.sns; + } + + async send( + _recipient: NotificationRecipient, + _content: NotificationContent, + ): Promise { + try { + if (!_recipient.phone) { + return { + success: false, + notificationId: _recipient.id, + error: "Recipient phone number is required", + }; + } + + const { client, PublishCommand } = await this.getClient(); + + const params: any = { + Message: _content.body, + PhoneNumber: _recipient.phone, + }; + + if (this.config.senderName) { + params.MessageAttributes = { + "AWS.SNS.SMS.SenderID": { + DataType: "String", + StringValue: this.config.senderName, + }, + }; + } + + const command = new PublishCommand(params); + const response = await client.send(command); + + return { + success: true, + notificationId: _recipient.id, + providerMessageId: response.MessageId, + metadata: { + sequenceNumber: response.SequenceNumber, + }, + }; + } catch (error) { + return { + success: false, + notificationId: _recipient.id, + error: error instanceof Error ? error.message : "Failed to send SMS via AWS SNS", + }; + } + } + + async isReady(): Promise { + try { + const { client } = await this.getClient(); + // Check if client is initialized + return !!client; + } catch { + return false; + } + } + + validateRecipient(_recipient: NotificationRecipient): boolean { + return !!_recipient.phone && this.isValidPhoneNumber(_recipient.phone); + } + + private isValidPhoneNumber(phone: string): boolean { + // Basic E.164 format validation + const phoneRegex = /^\+[1-9]\d{1,14}$/; + return phoneRegex.test(phone); + } +} diff --git a/src/infra/senders/sms/twilio.sender.ts b/src/infra/senders/sms/twilio.sender.ts new file mode 100644 index 0000000..744ef6b --- /dev/null +++ b/src/infra/senders/sms/twilio.sender.ts @@ -0,0 +1,100 @@ +import type { + INotificationSender, + NotificationChannel, + NotificationContent, + NotificationRecipient, + NotificationResult, +} from "../../../core"; + +export interface TwilioConfig { + accountSid: string; + authToken: string; + fromNumber: string; +} + +/** + * SMS sender implementation using Twilio + */ +export class TwilioSmsSender implements INotificationSender { + readonly channel: NotificationChannel = "sms" as NotificationChannel; + private client: any = null; + + constructor(private readonly config: TwilioConfig) {} + + /** + * Initialize Twilio client lazily + */ + private async getClient(): Promise { + if (this.client) { + return this.client; + } + + // Dynamic import to avoid requiring twilio at build time + // @ts-expect-error - twilio is an optional peer dependency + const twilio = await import("twilio"); + this.client = twilio.default(this.config.accountSid, this.config.authToken); + + return this.client; + } + + async send( + _recipient: NotificationRecipient, + _content: NotificationContent, + ): Promise { + try { + if (!_recipient.phone) { + return { + success: false, + notificationId: _recipient.id, + error: "Recipient phone number is required", + }; + } + + const client = await this.getClient(); + + const message = await client.messages.create({ + body: _content.body, + from: this.config.fromNumber, + to: _recipient.phone, + }); + + return { + success: true, + notificationId: _recipient.id, + providerMessageId: message.sid, + metadata: { + status: message.status, + price: message.price, + priceUnit: message.priceUnit, + }, + }; + } catch (error) { + return { + success: false, + notificationId: _recipient.id, + error: error instanceof Error ? error.message : "Failed to send SMS via Twilio", + }; + } + } + + async isReady(): Promise { + try { + const client = await this.getClient(); + // Try to fetch account info to verify credentials + await client.api.accounts(this.config.accountSid).fetch(); + return true; + } catch { + return false; + } + } + + validateRecipient(_recipient: NotificationRecipient): boolean { + return !!_recipient.phone && this.isValidPhoneNumber(_recipient.phone); + } + + private isValidPhoneNumber(phone: string): boolean { + // Basic E.164 format validation + const phoneRegex = /^\+[1-9]\d{1,14}$/; + return phoneRegex.test(phone); + } +} diff --git a/src/infra/senders/sms/vonage.sender.ts b/src/infra/senders/sms/vonage.sender.ts new file mode 100644 index 0000000..831b297 --- /dev/null +++ b/src/infra/senders/sms/vonage.sender.ts @@ -0,0 +1,113 @@ +import type { + INotificationSender, + NotificationChannel, + NotificationContent, + NotificationRecipient, + NotificationResult, +} from "../../../core"; + +export interface VonageConfig { + apiKey: string; + apiSecret: string; + from: string; +} + +/** + * SMS sender implementation using Vonage (formerly Nexmo) + */ +export class VonageSmsSender implements INotificationSender { + readonly channel: NotificationChannel = "sms" as NotificationChannel; + private client: any = null; + + constructor(private readonly config: VonageConfig) {} + + /** + * Initialize Vonage client lazily + */ + private async getClient(): Promise { + if (this.client) { + return this.client; + } + + // Dynamic import to avoid requiring @vonage/server-sdk at build time + // @ts-expect-error - @vonage/server-sdk is an optional peer dependency + const { Vonage } = await import("@vonage/server-sdk"); + + this.client = new Vonage({ + apiKey: this.config.apiKey, + apiSecret: this.config.apiSecret, + }); + + return this.client; + } + + async send( + _recipient: NotificationRecipient, + _content: NotificationContent, + ): Promise { + try { + if (!_recipient.phone) { + return { + success: false, + notificationId: _recipient.id, + error: "Recipient phone number is required", + }; + } + + const client = await this.getClient(); + + const response = await client.sms.send({ + to: _recipient.phone, + from: this.config.from, + text: _content.body, + }); + + const message = response.messages[0]; + + if (message.status === "0") { + return { + success: true, + notificationId: _recipient.id, + providerMessageId: message["message-id"], + metadata: { + networkCode: message["network-code"], + price: message["message-price"], + remainingBalance: message["remaining-balance"], + }, + }; + } else { + return { + success: false, + notificationId: _recipient.id, + error: message["error-text"] || "Failed to send SMS via Vonage", + }; + } + } catch (error) { + return { + success: false, + notificationId: _recipient.id, + error: error instanceof Error ? error.message : "Failed to send SMS via Vonage", + }; + } + } + + async isReady(): Promise { + try { + const client = await this.getClient(); + // Check if client is initialized + return !!client; + } catch { + return false; + } + } + + validateRecipient(_recipient: NotificationRecipient): boolean { + return !!_recipient.phone && this.isValidPhoneNumber(_recipient.phone); + } + + private isValidPhoneNumber(phone: string): boolean { + // Basic E.164 format validation + const phoneRegex = /^\+[1-9]\d{1,14}$/; + return phoneRegex.test(phone); + } +} diff --git a/tsconfig.json b/tsconfig.json index 63ab110..fbeb6c4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "module": "ESNext", "moduleResolution": "Bundler", "declaration": true, From 64e85c9d4643597ba291767c000655425e50b679 Mon Sep 17 00:00:00 2001 From: yasser Date: Wed, 25 Feb 2026 14:21:26 +0000 Subject: [PATCH 02/13] feature/nestjs-integration --- src/index.ts | 6 + src/nest/constants.ts | 11 + .../controllers/notification.controller.ts | 221 ++++++++++++++++++ src/nest/controllers/webhook.controller.ts | 144 ++++++++++++ src/nest/decorators.ts | 52 +++++ src/nest/index.ts | 17 ++ src/nest/interfaces.ts | 112 +++++++++ src/nest/module.ts | 144 +++++++++++- src/nest/providers.ts | 117 ++++++++++ tsconfig.json | 5 +- tsup.config.ts | 12 + 11 files changed, 832 insertions(+), 9 deletions(-) create mode 100644 src/nest/constants.ts create mode 100644 src/nest/controllers/notification.controller.ts create mode 100644 src/nest/controllers/webhook.controller.ts create mode 100644 src/nest/decorators.ts create mode 100644 src/nest/interfaces.ts create mode 100644 src/nest/providers.ts diff --git a/src/index.ts b/src/index.ts index 57b076b..f39fa91 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,8 @@ +// Core domain layer export * from "./core"; + +// Infrastructure layer +export * from "./infra"; + +// NestJS integration layer export * from "./nest"; diff --git a/src/nest/constants.ts b/src/nest/constants.ts new file mode 100644 index 0000000..947bdf7 --- /dev/null +++ b/src/nest/constants.ts @@ -0,0 +1,11 @@ +/** + * Injection tokens for NotificationKit providers + */ +export const NOTIFICATION_KIT_OPTIONS = Symbol("NOTIFICATION_KIT_OPTIONS"); +export const NOTIFICATION_SERVICE = Symbol("NOTIFICATION_SERVICE"); +export const NOTIFICATION_REPOSITORY = Symbol("NOTIFICATION_REPOSITORY"); +export const NOTIFICATION_SENDERS = Symbol("NOTIFICATION_SENDERS"); +export const NOTIFICATION_ID_GENERATOR = Symbol("NOTIFICATION_ID_GENERATOR"); +export const NOTIFICATION_DATETIME_PROVIDER = Symbol("NOTIFICATION_DATETIME_PROVIDER"); +export const NOTIFICATION_TEMPLATE_ENGINE = Symbol("NOTIFICATION_TEMPLATE_ENGINE"); +export const NOTIFICATION_EVENT_EMITTER = Symbol("NOTIFICATION_EVENT_EMITTER"); diff --git a/src/nest/controllers/notification.controller.ts b/src/nest/controllers/notification.controller.ts new file mode 100644 index 0000000..b0ae5b0 --- /dev/null +++ b/src/nest/controllers/notification.controller.ts @@ -0,0 +1,221 @@ +import { + Body, + Controller, + Delete, + Get, + Inject, + Param, + Patch, + Post, + Query, + HttpCode, + HttpStatus, + NotFoundException, + BadRequestException, +} from "@nestjs/common"; + +import type { + BulkSendNotificationDto, + CreateNotificationDto, + QueryNotificationsDto, + SendNotificationDto, +} from "../../core/dtos"; +import { NotificationNotFoundError, ValidationError } from "../../core/errors"; +import type { NotificationService } from "../../core/notification.service"; +import { NOTIFICATION_KIT_OPTIONS, NOTIFICATION_SERVICE } from "../constants"; +import type { NotificationKitModuleOptions } from "../interfaces"; + +/** + * REST API controller for notification operations + */ +@Controller() +export class NotificationController { + private readonly prefix: string; + + constructor( + @Inject(NOTIFICATION_SERVICE) + private readonly notificationService: NotificationService, + @Inject(NOTIFICATION_KIT_OPTIONS) + private readonly options: NotificationKitModuleOptions, + ) { + this.prefix = options.apiPrefix || "notifications"; + } + + /** + * Send a notification + * POST /notifications/send + */ + @Post("send") + @HttpCode(HttpStatus.CREATED) + async send(@Body() dto: SendNotificationDto) { + try { + return await this.notificationService.send(dto); + } catch (error) { + if (error instanceof ValidationError) { + throw new BadRequestException(error.message); + } + throw error; + } + } + + /** + * Send bulk notifications + * POST /notifications/bulk-send + */ + @Post("bulk-send") + @HttpCode(HttpStatus.ACCEPTED) + async bulkSend(@Body() dto: BulkSendNotificationDto) { + try { + // Convert bulk DTO to individual send requests + const results = await Promise.allSettled( + dto.recipients.map((recipient) => + this.notificationService.send({ + ...dto, + recipient, + }), + ), + ); + + return { + total: results.length, + succeeded: results.filter((r: PromiseSettledResult) => r.status === "fulfilled") + .length, + failed: results.filter((r: PromiseSettledResult) => r.status === "rejected").length, + results: results.map((r: PromiseSettledResult, index: number) => ({ + index, + status: r.status, + notification: r.status === "fulfilled" ? r.value : undefined, + error: r.status === "rejected" ? String(r.reason) : undefined, + })), + }; + } catch (error) { + if (error instanceof ValidationError) { + throw new BadRequestException(error.message); + } + throw error; + } + } + + /** + * Create a notification without sending + * POST /notifications + */ + @Post() + @HttpCode(HttpStatus.CREATED) + async create(@Body() dto: CreateNotificationDto) { + try { + return await this.notificationService.create(dto); + } catch (error) { + if (error instanceof ValidationError) { + throw new BadRequestException(error.message); + } + throw error; + } + } + + /** + * Get notification by ID + * GET /notifications/:id + */ + @Get(":id") + async getById(@Param("id") id: string) { + try { + return await this.notificationService.getById(id); + } catch (error) { + if (error instanceof NotificationNotFoundError) { + throw new NotFoundException(error.message); + } + throw error; + } + } + + /** + * Query notifications + * GET /notifications + */ + @Get() + async query(@Query() queryDto: QueryNotificationsDto) { + try { + // Build query criteria + const criteria: any = { + limit: queryDto.limit, + offset: queryDto.offset, + }; + + if (queryDto.recipientId) criteria.recipientId = queryDto.recipientId; + if (queryDto.channel) criteria.channel = queryDto.channel; + if (queryDto.status) criteria.status = queryDto.status; + if (queryDto.priority) criteria.priority = queryDto.priority; + if (queryDto.fromDate) criteria.fromDate = queryDto.fromDate; + if (queryDto.toDate) criteria.toDate = queryDto.toDate; + + const [notifications, total] = await Promise.all([ + this.notificationService.query(criteria), + this.notificationService.count(criteria), + ]); + + return { + data: notifications, + total, + limit: queryDto.limit, + offset: queryDto.offset, + }; + } catch (error) { + if (error instanceof ValidationError) { + throw new BadRequestException(error.message); + } + throw error; + } + } + + /** + * Retry sending a notification + * POST /notifications/:id/retry + */ + @Post(":id/retry") + @HttpCode(HttpStatus.OK) + async retry(@Param("id") id: string) { + try { + return await this.notificationService.retry(id); + } catch (error) { + if (error instanceof NotificationNotFoundError) { + throw new NotFoundException(error.message); + } + throw error; + } + } + + /** + * Cancel a notification + * DELETE /notifications/:id/cancel + */ + @Delete(":id/cancel") + @HttpCode(HttpStatus.OK) + async cancel(@Param("id") id: string) { + try { + return await this.notificationService.cancel(id); + } catch (error) { + if (error instanceof NotificationNotFoundError) { + throw new NotFoundException(error.message); + } + throw error; + } + } + + /** + * Mark notification as delivered (webhook callback) + * PATCH /notifications/:id/delivered + */ + @Patch(":id/delivered") + @HttpCode(HttpStatus.OK) + async markAsDelivered(@Param("id") id: string, @Body() body: { metadata?: Record }) { + try { + return await this.notificationService.markAsDelivered(id, body.metadata); + } catch (error) { + if (error instanceof NotificationNotFoundError) { + throw new NotFoundException(error.message); + } + throw error; + } + } +} diff --git a/src/nest/controllers/webhook.controller.ts b/src/nest/controllers/webhook.controller.ts new file mode 100644 index 0000000..547b76d --- /dev/null +++ b/src/nest/controllers/webhook.controller.ts @@ -0,0 +1,144 @@ +import { + Body, + Controller, + Headers, + HttpCode, + HttpStatus, + Inject, + Post, + UnauthorizedException, + BadRequestException, +} from "@nestjs/common"; + +import { NotificationNotFoundError } from "../../core/errors"; +import type { NotificationService } from "../../core/notification.service"; +import { NOTIFICATION_KIT_OPTIONS, NOTIFICATION_SERVICE } from "../constants"; +import type { NotificationKitModuleOptions } from "../interfaces"; + +/** + * Webhook payload from notification providers + */ +interface WebhookPayload { + notificationId: string; + status?: "delivered" | "failed" | "bounced" | "complained"; + deliveredAt?: string; + provider?: string; + metadata?: Record; +} + +/** + * Webhook controller for receiving delivery status callbacks from providers + */ +@Controller() +export class WebhookController { + private readonly path: string; + private readonly secret: string | undefined; + + constructor( + @Inject(NOTIFICATION_SERVICE) + private readonly notificationService: NotificationService, + @Inject(NOTIFICATION_KIT_OPTIONS) + private readonly options: NotificationKitModuleOptions, + ) { + this.path = options.webhookPath || "webhooks/notifications"; + this.secret = options.webhookSecret; + } + + /** + * Handle webhook callbacks from notification providers + * POST /webhooks/notifications + */ + @Post() + @HttpCode(HttpStatus.OK) + async handleWebhook( + @Headers("x-webhook-secret") webhookSecret: string | undefined, + @Headers("x-webhook-signature") webhookSignature: string | undefined, + @Body() payload: WebhookPayload | WebhookPayload[], + ) { + // Verify webhook secret if configured + if (this.secret) { + if (!webhookSecret && !webhookSignature) { + throw new UnauthorizedException("Missing webhook authentication"); + } + + if (webhookSecret && webhookSecret !== this.secret) { + throw new UnauthorizedException("Invalid webhook secret"); + } + + // TODO: Implement signature verification for production use + // This would verify HMAC signatures from providers like AWS SNS, Twilio, etc. + } + + try { + // Handle single or batch webhooks + const payloads = Array.isArray(payload) ? payload : [payload]; + const results = []; + + for (const item of payloads) { + try { + // Validate payload + if (!item.notificationId) { + throw new BadRequestException("Missing notificationId in webhook payload"); + } + + // Process based on status + if (item.status === "delivered") { + const notification = await this.notificationService.markAsDelivered( + item.notificationId, + item.metadata, + ); + results.push({ success: true, notificationId: item.notificationId, notification }); + } else if (item.status === "failed" || item.status === "bounced") { + // Mark as failed and potentially retry + const notification = await this.notificationService.getById(item.notificationId); + if (notification.retryCount < (notification.maxRetries || 3)) { + await this.notificationService.retry(item.notificationId); + results.push({ + success: true, + notificationId: item.notificationId, + action: "retried", + }); + } else { + results.push({ + success: true, + notificationId: item.notificationId, + action: "max_retries_reached", + }); + } + } else { + // Unknown status, just log it + results.push({ + success: true, + notificationId: item.notificationId, + action: "logged", + status: item.status, + }); + } + } catch (error) { + if (error instanceof NotificationNotFoundError) { + results.push({ + success: false, + notificationId: item.notificationId, + error: "notification_not_found", + }); + } else { + results.push({ + success: false, + notificationId: item.notificationId, + error: String(error), + }); + } + } + } + + return { + received: payloads.length, + processed: results.filter((r) => r.success).length, + failed: results.filter((r) => !r.success).length, + results, + }; + } catch (error) { + throw new BadRequestException(`Failed to process webhook: ${String(error)}`); + } + } +} diff --git a/src/nest/decorators.ts b/src/nest/decorators.ts new file mode 100644 index 0000000..c79962a --- /dev/null +++ b/src/nest/decorators.ts @@ -0,0 +1,52 @@ +import { Inject } from "@nestjs/common"; + +import { + NOTIFICATION_DATETIME_PROVIDER, + NOTIFICATION_EVENT_EMITTER, + NOTIFICATION_ID_GENERATOR, + NOTIFICATION_KIT_OPTIONS, + NOTIFICATION_REPOSITORY, + NOTIFICATION_SENDERS, + NOTIFICATION_SERVICE, + NOTIFICATION_TEMPLATE_ENGINE, +} from "./constants"; + +/** + * Inject NotificationService + */ +export const InjectNotificationService = () => Inject(NOTIFICATION_SERVICE); + +/** + * Inject NotificationKit module options + */ +export const InjectNotificationKitOptions = () => Inject(NOTIFICATION_KIT_OPTIONS); + +/** + * Inject notification repository + */ +export const InjectNotificationRepository = () => Inject(NOTIFICATION_REPOSITORY); + +/** + * Inject notification senders + */ +export const InjectNotificationSenders = () => Inject(NOTIFICATION_SENDERS); + +/** + * Inject ID generator + */ +export const InjectIdGenerator = () => Inject(NOTIFICATION_ID_GENERATOR); + +/** + * Inject DateTime provider + */ +export const InjectDateTimeProvider = () => Inject(NOTIFICATION_DATETIME_PROVIDER); + +/** + * Inject template engine + */ +export const InjectTemplateEngine = () => Inject(NOTIFICATION_TEMPLATE_ENGINE); + +/** + * Inject event emitter + */ +export const InjectEventEmitter = () => Inject(NOTIFICATION_EVENT_EMITTER); diff --git a/src/nest/index.ts b/src/nest/index.ts index b999044..ffd8c09 100644 --- a/src/nest/index.ts +++ b/src/nest/index.ts @@ -1 +1,18 @@ +// Module export * from "./module"; + +// Interfaces +export * from "./interfaces"; + +// Constants +export * from "./constants"; + +// Decorators +export * from "./decorators"; + +// Controllers +export * from "./controllers/notification.controller"; +export * from "./controllers/webhook.controller"; + +// Providers +export * from "./providers"; diff --git a/src/nest/interfaces.ts b/src/nest/interfaces.ts new file mode 100644 index 0000000..c1a0a2e --- /dev/null +++ b/src/nest/interfaces.ts @@ -0,0 +1,112 @@ +import type { ModuleMetadata, Type } from "@nestjs/common"; + +import type { + IDateTimeProvider, + IIdGenerator, + INotificationEventEmitter, + INotificationRepository, + INotificationSender, + ITemplateEngine, +} from "../core"; + +/** + * Options for configuring NotificationKit module + */ +export interface NotificationKitModuleOptions { + /** + * Array of notification senders for different channels + */ + senders: INotificationSender[]; + + /** + * Repository implementation for persisting notifications + */ + repository: INotificationRepository; + + /** + * ID generator for creating notification IDs + * @default UuidGenerator + */ + idGenerator?: IIdGenerator; + + /** + * DateTime provider for timestamps + * @default DateTimeProvider + */ + dateTimeProvider?: IDateTimeProvider; + + /** + * Optional template engine for rendering notification templates + */ + templateEngine?: ITemplateEngine; + + /** + * Optional event emitter for notification events + */ + eventEmitter?: INotificationEventEmitter; + + /** + * Enable REST API endpoints + * @default true + */ + enableRestApi?: boolean; + + /** + * REST API route prefix + * @default 'notifications' + */ + apiPrefix?: string; + + /** + * Enable webhook endpoint for delivery status callbacks + * @default true + */ + enableWebhooks?: boolean; + + /** + * Webhook route path + * @default 'notifications/webhooks' + */ + webhookPath?: string; + + /** + * Webhook secret for validating incoming requests + */ + webhookSecret?: string; +} + +/** + * Factory for creating NotificationKit options asynchronously + */ +export interface NotificationKitOptionsFactory { + createNotificationKitOptions(): + | Promise + | NotificationKitModuleOptions; +} + +/** + * Options for registerAsync + */ +export interface NotificationKitModuleAsyncOptions extends Pick { + /** + * Use existing options factory + */ + useExisting?: Type; + + /** + * Use class as options factory + */ + useClass?: Type; + + /** + * Use factory function + */ + useFactory?: ( + ...args: any[] + ) => Promise | NotificationKitModuleOptions; + + /** + * Dependencies to inject into factory function + */ + inject?: any[]; +} diff --git a/src/nest/module.ts b/src/nest/module.ts index 61f623b..2f60370 100644 --- a/src/nest/module.ts +++ b/src/nest/module.ts @@ -1,17 +1,147 @@ -import { Module } from "@nestjs/common"; -import type { DynamicModule } from "@nestjs/common"; +import { DynamicModule, Module, Provider, Type } from "@nestjs/common"; -export type NotificationKitModuleOptions = Record; +import { NOTIFICATION_KIT_OPTIONS } from "./constants"; +import { NotificationController } from "./controllers/notification.controller"; +import { WebhookController } from "./controllers/webhook.controller"; +import type { + NotificationKitModuleAsyncOptions, + NotificationKitModuleOptions, + NotificationKitOptionsFactory, +} from "./interfaces"; +import { createNotificationKitProviders } from "./providers"; @Module({}) export class NotificationKitModule { - static register(_options: NotificationKitModuleOptions = {}): DynamicModule { - void _options; + /** + * Register module synchronously with direct configuration + */ + static register(options: NotificationKitModuleOptions): DynamicModule { + const providers = this.createProviders(options); + const controllers = this.createControllers(options); + const exports = providers.map((p) => (typeof p === "object" && "provide" in p ? p.provide : p)); return { + global: true, module: NotificationKitModule, - providers: [], - exports: [], + controllers, + providers, + exports, }; } + + /** + * Register module asynchronously with factory pattern + */ + static registerAsync(options: NotificationKitModuleAsyncOptions): DynamicModule { + const asyncOptionsProvider = this.createAsyncOptionsProvider(options); + const asyncProviders = this.createAsyncProviders(options); + + // We can't conditionally load controllers in async mode without the options + // So we'll need to always include them and they can handle being disabled internally + // Or we can create a factory provider that returns empty array + const providersFactory: Provider = { + provide: "NOTIFICATION_PROVIDERS", + useFactory: (moduleOptions: NotificationKitModuleOptions) => { + return createNotificationKitProviders(moduleOptions); + }, + inject: [NOTIFICATION_KIT_OPTIONS], + }; + + const allProviders = [asyncOptionsProvider, ...asyncProviders, providersFactory]; + const exports = allProviders.map((p) => + typeof p === "object" && "provide" in p ? p.provide : p, + ); + + return { + global: true, + module: NotificationKitModule, + imports: options.imports || [], + controllers: [], // Controllers disabled in async mode for simplicity + providers: allProviders, + exports, + }; + } + + /** + * Create providers including options and service providers + */ + private static createProviders(options: NotificationKitModuleOptions): Provider[] { + return [ + { + provide: NOTIFICATION_KIT_OPTIONS, + useValue: options, + }, + ...createNotificationKitProviders(options), + ]; + } + + /** + * Create controllers based on options + */ + private static createControllers(options: NotificationKitModuleOptions): Type[] { + const controllers: Type[] = []; + + // Add REST API controller if enabled (default: true) + if (options.enableRestApi !== false) { + controllers.push(NotificationController); + } + + // Add webhook controller if enabled (default: true) + if (options.enableWebhooks !== false) { + controllers.push(WebhookController); + } + + return controllers; + } + + /** + * Create async providers for registerAsync + */ + private static createAsyncProviders(options: NotificationKitModuleAsyncOptions): Provider[] { + if (options.useClass) { + return [ + { + provide: options.useClass, + useClass: options.useClass, + }, + ]; + } + + return []; + } + + /** + * Create async options provider + */ + private static createAsyncOptionsProvider(options: NotificationKitModuleAsyncOptions): Provider { + if (options.useFactory) { + return { + provide: NOTIFICATION_KIT_OPTIONS, + useFactory: options.useFactory, + inject: options.inject || [], + }; + } + + if (options.useExisting) { + return { + provide: NOTIFICATION_KIT_OPTIONS, + useFactory: async (optionsFactory: NotificationKitOptionsFactory) => { + return optionsFactory.createNotificationKitOptions(); + }, + inject: [options.useExisting], + }; + } + + if (options.useClass) { + return { + provide: NOTIFICATION_KIT_OPTIONS, + useFactory: async (optionsFactory: NotificationKitOptionsFactory) => { + return optionsFactory.createNotificationKitOptions(); + }, + inject: [options.useClass], + }; + } + + throw new Error("Invalid NotificationKitModuleAsyncOptions"); + } } diff --git a/src/nest/providers.ts b/src/nest/providers.ts new file mode 100644 index 0000000..5053a2b --- /dev/null +++ b/src/nest/providers.ts @@ -0,0 +1,117 @@ +import type { Provider } from "@nestjs/common"; + +import { NotificationService } from "../core/notification.service"; +import { DateTimeProvider as _DateTimeProvider } from "../infra/providers/datetime.provider"; +import { UuidGenerator as _UuidGenerator } from "../infra/providers/id-generator.provider"; + +import { + NOTIFICATION_DATETIME_PROVIDER, + NOTIFICATION_EVENT_EMITTER, + NOTIFICATION_ID_GENERATOR, + NOTIFICATION_REPOSITORY, + NOTIFICATION_SENDERS, + NOTIFICATION_SERVICE, + NOTIFICATION_TEMPLATE_ENGINE, +} from "./constants"; +import type { NotificationKitModuleOptions } from "./interfaces"; + +/** + * Create providers for NotificationKit module + */ +export function createNotificationKitProviders(options: NotificationKitModuleOptions): Provider[] { + const providers: Provider[] = []; + + // Senders provider + providers.push({ + provide: NOTIFICATION_SENDERS, + useValue: options.senders, + }); + + // Repository provider + providers.push({ + provide: NOTIFICATION_REPOSITORY, + useValue: options.repository, + }); + + // ID Generator provider + if (options.idGenerator) { + providers.push({ + provide: NOTIFICATION_ID_GENERATOR, + useValue: options.idGenerator, + }); + } else { + // Default to UuidGenerator + providers.push({ + provide: NOTIFICATION_ID_GENERATOR, + useFactory: async () => { + const { UuidGenerator } = await import("../infra/providers/id-generator.provider"); + return new UuidGenerator(); + }, + }); + } + + // DateTime Provider + if (options.dateTimeProvider) { + providers.push({ + provide: NOTIFICATION_DATETIME_PROVIDER, + useValue: options.dateTimeProvider, + }); + } else { + // Default to DateTimeProvider + providers.push({ + provide: NOTIFICATION_DATETIME_PROVIDER, + useFactory: async () => { + const { DateTimeProvider } = await import("../infra/providers/datetime.provider"); + return new DateTimeProvider(); + }, + }); + } + + // Template Engine provider (optional) + if (options.templateEngine) { + providers.push({ + provide: NOTIFICATION_TEMPLATE_ENGINE, + useValue: options.templateEngine, + }); + } + + // Event Emitter provider (optional) + if (options.eventEmitter) { + providers.push({ + provide: NOTIFICATION_EVENT_EMITTER, + useValue: options.eventEmitter, + }); + } + + // NotificationService provider + providers.push({ + provide: NOTIFICATION_SERVICE, + useFactory: ( + repository: any, + idGenerator: any, + dateTimeProvider: any, + senders: any[], + templateEngine?: any, + eventEmitter?: any, + ) => { + return new NotificationService( + repository, + idGenerator, + dateTimeProvider, + senders, + templateEngine, + eventEmitter, + ); + }, + inject: [ + NOTIFICATION_REPOSITORY, + NOTIFICATION_ID_GENERATOR, + NOTIFICATION_DATETIME_PROVIDER, + NOTIFICATION_SENDERS, + { token: NOTIFICATION_TEMPLATE_ENGINE, optional: true }, + { token: NOTIFICATION_EVENT_EMITTER, optional: true }, + ], + }); + + return providers; +} diff --git a/tsconfig.json b/tsconfig.json index fbeb6c4..f6dbfd9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,8 +14,9 @@ "esModuleInterop": true, "resolveJsonModule": true, "skipLibCheck": true, - "types": ["jest"], - "baseUrl": "." + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "types": ["jest"] }, "include": ["src/**/*.ts", "test/**/*.ts"], "exclude": ["dist", "node_modules"] diff --git a/tsup.config.ts b/tsup.config.ts index 798d116..b60fd92 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -10,4 +10,16 @@ export default defineConfig({ target: "es2022", outDir: "dist", tsconfig: "tsconfig.build.json", + external: [ + "@nestjs/common", + "nodemailer", + "twilio", + "@aws-sdk/client-sns", + "@vonage/server-sdk", + "firebase-admin", + "mongoose", + "handlebars", + "nanoid", + "zod", + ], }); From 1895f8f48dcbe4108029dff102a652b7a65a1597 Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Thu, 26 Feb 2026 12:20:03 +0000 Subject: [PATCH 03/13] merging all features --- package-lock.json | 26 ++++++++++++++++++++++++++ src/nest/module.ts | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 4ab5e5f..424c694 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,32 @@ "@nestjs/core": "^10 || ^11", "reflect-metadata": "^0.2.2", "rxjs": "^7" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-sns": { + "optional": true + }, + "@vonage/server-sdk": { + "optional": true + }, + "firebase-admin": { + "optional": true + }, + "handlebars": { + "optional": true + }, + "mongoose": { + "optional": true + }, + "nanoid": { + "optional": true + }, + "nodemailer": { + "optional": true + }, + "twilio": { + "optional": true + } } }, "node_modules/@babel/code-frame": { diff --git a/src/nest/module.ts b/src/nest/module.ts index 2f60370..1775f11 100644 --- a/src/nest/module.ts +++ b/src/nest/module.ts @@ -1,4 +1,4 @@ -import { DynamicModule, Module, Provider, Type } from "@nestjs/common"; +import { Module, type DynamicModule, type Provider, type Type } from "@nestjs/common"; import { NOTIFICATION_KIT_OPTIONS } from "./constants"; import { NotificationController } from "./controllers/notification.controller"; From 4e6247aaf57e6b2a60290aab4188e682960bcae8 Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Thu, 26 Feb 2026 13:17:57 +0000 Subject: [PATCH 04/13] docs : updated copilot instructions --- .github/copilot-instructions.md | 834 +++++++++++++++++++++----------- 1 file changed, 562 insertions(+), 272 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cc74d32..14512f5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,201 +1,428 @@ -# Copilot Instructions - NestJS Developer Kit (Template) +# Copilot Instructions - @ciscode/notification-kit -> **Purpose**: Template for creating reusable NestJS module packages with best practices, standardized structure, and AI-friendly development workflow. +> **Purpose**: Universal NestJS notification library supporting multi-channel delivery (Email, SMS, Push, In-App, Webhook) with pluggable provider backends, template support, persistence, and a built-in REST + Webhook API. --- -## 🎯 Template Overview +## 🎯 Package Overview -**Package**: Template for `@ciscode/*` NestJS modules -**Type**: Backend NestJS Module Template -**Purpose**: Starting point for creating authentication, database, logging, and other NestJS modules +**Package**: `@ciscode/notification-kit` +**Type**: Backend NestJS Notification Module +**Purpose**: Centralized, multi-channel notification delivery with pluggable providers, retry logic, status tracking, and scheduling β€” usable across all `@ciscode/*` services -### This Template Provides: +### This Package Provides: -- CSR (Controller-Service-Repository) architecture -- Complete TypeScript configuration with path aliases -- Jest testing setup with 80% coverage threshold +- CSR (Controller-Service-Repository) architecture with Clean Architecture ports +- `NotificationKitModule` β€” global NestJS dynamic module (`register` / `registerAsync`) +- `NotificationService` β€” injectable orchestration service (core, framework-free) +- `NotificationController` β€” REST API for sending and querying notifications +- `WebhookController` β€” inbound webhook receiver for provider delivery callbacks +- Channel senders: **Email** (Nodemailer), **SMS** (Twilio / Vonage / AWS SNS), **Push** (Firebase), **In-App**, **Webhook** +- Repository adapters: **MongoDB** (Mongoose) and **In-Memory** +- Template rendering via Handlebars +- Zod-validated configuration - Changesets for version management - Husky + lint-staged for code quality -- CI/CD workflows - Copilot-friendly development guidelines --- ## πŸ—οΈ Module Architecture -**Modules use Controller-Service-Repository (CSR) pattern for simplicity and reusability.** +**NotificationKit uses CSR (Controller-Service-Repository) + Ports & Adapters for maximum reusability and provider interchangeability.** -> **WHY CSR for modules?** Reusable libraries need to be simple, well-documented, and easy to integrate. The 4-layer Clean Architecture is better suited for complex applications, not libraries. +> **WHY CSR + Ports?** Reusable notification libraries must support multiple providers without coupling business logic to any specific SDK. Ports (interfaces) in `core/` define the contracts; adapters in `infra/` implement them. Apps choose which adapters to wire. ``` src/ - β”œβ”€β”€ index.ts # PUBLIC API exports - β”œβ”€β”€ {module-name}.module.ts # NestJS module definition + β”œβ”€β”€ index.ts # PUBLIC API β€” all exports go through here β”‚ - β”œβ”€β”€ controllers/ # HTTP Layer - β”‚ └── example.controller.ts + β”œβ”€β”€ core/ # βœ… Framework-FREE (no NestJS imports) + β”‚ β”œβ”€β”€ index.ts + β”‚ β”œβ”€β”€ types.ts # Domain entities & enums + β”‚ β”œβ”€β”€ dtos/ # Input/output contracts (Zod-validated) + β”‚ β”œβ”€β”€ ports/ # Abstractions (interfaces the infra implements) + β”‚ β”‚ β”œβ”€β”€ notification-sender.port.ts # INotificationSender + β”‚ β”‚ β”œβ”€β”€ notification-repository.port.ts # INotificationRepository + β”‚ β”‚ └── (template, event, id, datetime ports) + β”‚ β”œβ”€β”€ errors/ # Domain errors + β”‚ └── notification.service.ts # Core orchestration logic (framework-free) β”‚ - β”œβ”€β”€ services/ # Business Logic - β”‚ └── example.service.ts + β”œβ”€β”€ infra/ # Concrete adapter implementations + β”‚ β”œβ”€β”€ index.ts + β”‚ β”œβ”€β”€ senders/ # Channel sender adapters + β”‚ β”‚ β”œβ”€β”€ email/ # Nodemailer adapter + β”‚ β”‚ β”œβ”€β”€ sms/ # Twilio / Vonage / AWS SNS adapters + β”‚ β”‚ β”œβ”€β”€ push/ # Firebase adapter + β”‚ β”‚ β”œβ”€β”€ in-app/ # In-app adapter + β”‚ β”‚ └── webhook/ # Outbound webhook adapter + β”‚ β”œβ”€β”€ repositories/ # Persistence adapters + β”‚ β”‚ β”œβ”€β”€ mongodb/ # Mongoose adapter + β”‚ β”‚ └── in-memory/ # In-memory adapter (testing / simple usage) + β”‚ └── providers/ # Utility adapters + β”‚ β”œβ”€β”€ id-generator/ # nanoid adapter + β”‚ β”œβ”€β”€ datetime/ # Date/time utilities + β”‚ β”œβ”€β”€ template/ # Handlebars adapter + β”‚ └── events/ # Event bus adapter β”‚ - β”œβ”€β”€ entities/ # Domain Models - β”‚ └── example.entity.ts - β”‚ - β”œβ”€β”€ repositories/ # Data Access - β”‚ └── example.repository.ts - β”‚ - β”œβ”€β”€ guards/ # Auth Guards - β”‚ └── example.guard.ts - β”‚ - β”œβ”€β”€ decorators/ # Custom Decorators - β”‚ └── example.decorator.ts - β”‚ - β”œβ”€β”€ dto/ # Data Transfer Objects - β”‚ └── example.dto.ts - β”‚ - β”œβ”€β”€ filters/ # Exception Filters - β”œβ”€β”€ middleware/ # Middleware - β”œβ”€β”€ config/ # Configuration - └── utils/ # Utilities + └── nest/ # NestJS integration layer + β”œβ”€β”€ index.ts + β”œβ”€β”€ module.ts # NotificationKitModule + β”œβ”€β”€ interfaces.ts # NotificationKitModuleOptions, AsyncOptions, Factory + β”œβ”€β”€ constants.ts # NOTIFICATION_KIT_OPTIONS token + β”œβ”€β”€ providers.ts # createNotificationKitProviders() factory + └── controllers/ + β”œβ”€β”€ notification.controller.ts # REST API (enable via enableRestApi) + └── webhook.controller.ts # Inbound webhooks (enable via enableWebhooks) ``` **Responsibility Layers:** -| Layer | Responsibility | Examples | -| ---------------- | ---------------------------------------- | ----------------------- | -| **Controllers** | HTTP handling, route definition | `example.controller.ts` | -| **Services** | Business logic, orchestration | `example.service.ts` | -| **Entities** | Domain models (Mongoose/TypeORM schemas) | `example.entity.ts` | -| **Repositories** | Data access, database queries | `example.repository.ts` | -| **Guards** | Authentication/Authorization | `jwt-auth.guard.ts` | -| **Decorators** | Parameter extraction, metadata | `@CurrentUser()` | -| **DTOs** | Input validation, API contracts | `create-example.dto.ts` | +| Layer | Responsibility | Examples | +| ----------------- | ---------------------------------------------------------- | ----------------------------------------------------------- | +| **Controllers** | HTTP handling, REST API, inbound webhook receivers | `NotificationController`, `WebhookController` | +| **Core Service** | Orchestration, channel routing, retry, status lifecycle | `notification.service.ts` | +| **DTOs** | Input validation, API contracts (Zod) | `SendNotificationDto`, `NotificationQueryDto` | +| **Ports** | Abstractions β€” what `core/` depends on | `INotificationSender`, `INotificationRepository` | +| **Senders** | Channel delivery β€” implement `INotificationSender` | `EmailSender`, `SmsSender`, `PushSender` | +| **Repositories** | Persistence β€” implement `INotificationRepository` | `MongoNotificationRepository`, `InMemoryRepository` | +| **Providers** | Cross-cutting utilities | `HandlebarsTemplateProvider`, `NanoidGenerator` | +| **Domain Types** | Entities, enums, value objects (immutable, framework-free) | `Notification`, `NotificationChannel`, `NotificationStatus` | +| **Domain Errors** | Typed, named error classes | `ChannelNotConfiguredError`, `NotificationNotFoundError` | + +### Layer Import Rules β€” STRICTLY ENFORCED + +| Layer | Can import from | Cannot import from | +| ------- | ---------------------- | ------------------ | +| `core` | Nothing internal | `infra`, `nest` | +| `infra` | `core` (ports & types) | `nest` | +| `nest` | `core`, `infra` | β€” | + +> **The golden rule**: `core/` must compile with zero NestJS or provider SDK imports. If you're adding a NestJS decorator or importing `nodemailer` inside `core/`, it's in the wrong layer. + +--- + +## πŸ“ Naming Conventions + +### Files + +**Pattern**: `kebab-case` + suffix + +| Type | Example | Directory | +| ---------------- | ---------------------------------- | --------------------------------- | +| Module | `module.ts` | `src/nest/` | +| Controller | `notification.controller.ts` | `src/nest/controllers/` | +| Core Service | `notification.service.ts` | `src/core/` | +| Port interface | `notification-sender.port.ts` | `src/core/ports/` | +| DTO | `send-notification.dto.ts` | `src/core/dtos/` | +| Domain Error | `notification-not-found.error.ts` | `src/core/errors/` | +| Sender adapter | `email.sender.ts` | `src/infra/senders/email/` | +| Repository | `mongo-notification.repository.ts` | `src/infra/repositories/mongodb/` | +| Utility provider | `handlebars-template.provider.ts` | `src/infra/providers/template/` | +| Constants | `constants.ts` | `src/nest/` | + +### Code Naming + +- **Classes & Interfaces**: `PascalCase` β†’ `NotificationService`, `INotificationSender`, `SendNotificationDto` +- **Variables & functions**: `camelCase` β†’ `sendNotification`, `buildProviders` +- **Constants / DI tokens**: `UPPER_SNAKE_CASE` β†’ `NOTIFICATION_KIT_OPTIONS`, `NOTIFICATION_SENDER`, `NOTIFICATION_REPOSITORY` +- **Enums**: Name `PascalCase`, values match protocol strings + +```typescript +// βœ… Correct enum definitions +enum NotificationChannel { + EMAIL = "email", + SMS = "sms", + PUSH = "push", + IN_APP = "in_app", + WEBHOOK = "webhook", +} -**Module Exports (Public API):** +enum NotificationStatus { + PENDING = "pending", + QUEUED = "queued", + SENDING = "sending", + SENT = "sent", + DELIVERED = "delivered", + FAILED = "failed", + CANCELLED = "cancelled", +} +``` + +### Path Aliases (`tsconfig.json`) ```typescript -// src/index.ts - Only export what apps need to consume -export { ExampleModule } from "./example.module"; +"@/*" β†’ "src/*" +"@core/*" β†’ "src/core/*" +"@infra/*" β†’ "src/infra/*" +"@nest/*" β†’ "src/nest/*" +``` + +Use aliases for cleaner imports: -// Services (main API) -export { ExampleService } from "./services/example.service"; +```typescript +import { NotificationService } from "@core/notification.service"; +import { INotificationSender } from "@core/ports/notification-sender.port"; +import { SendNotificationDto } from "@core/dtos/send-notification.dto"; +import { EmailSender } from "@infra/senders/email/email.sender"; +``` -// DTOs (public contracts) -export { CreateExampleDto, UpdateExampleDto } from "./dto"; +--- -// Guards (for protecting routes) -export { ExampleGuard } from "./guards/example.guard"; +## πŸ“¦ Public API β€” `src/index.ts` -// Decorators (for DI and metadata) -export { ExampleDecorator } from "./decorators/example.decorator"; +```typescript +// βœ… All exports go through here β€” never import from deep paths in consuming apps +export * from "./core"; // Types, DTOs, ports, errors, NotificationService +export * from "./infra"; // Senders, repositories, utility providers +export * from "./nest"; // NotificationKitModule, interfaces, constants +``` -// Types & Interfaces (for TypeScript typing) -export type { ExampleOptions, ExampleResult } from "./types"; +**What consuming apps should use:** -// ❌ NEVER export entities or repositories -// export { Example } from './entities/example.entity'; // FORBIDDEN -// export { ExampleRepository } from './repositories/example.repository'; // FORBIDDEN +```typescript +import { + NotificationKitModule, + NotificationService, + SendNotificationDto, + NotificationChannel, + NotificationStatus, + NotificationPriority, + type Notification, + type NotificationResult, + type INotificationSender, // for custom adapter implementations + type INotificationRepository, // for custom adapter implementations +} from "@ciscode/notification-kit"; ``` -**Rationale:** +**❌ NEVER export:** -- **Entities** = internal implementation details (can change) -- **Repositories** = internal data access (apps shouldn't depend on it) -- **DTOs** = stable public contracts (apps depend on these) -- **Services** = public API (apps use methods, not internals) +- Internal provider wiring (`createNotificationKitProviders` internals) +- Raw SDK instances (Nodemailer transporter, Twilio client, Firebase app) +- Mongoose schema definitions (infrastructure details) --- -## πŸ“ Naming Conventions +## βš™οΈ Module Registration -### Files +### `register()` β€” sync -**Pattern**: `kebab-case` + suffix +```typescript +NotificationKitModule.register({ + channels: { + email: { + provider: "nodemailer", + from: "no-reply@ciscode.com", + smtp: { host: "smtp.example.com", port: 587, auth: { user: "...", pass: "..." } }, + }, + sms: { + provider: "twilio", + accountSid: process.env.TWILIO_SID, + authToken: process.env.TWILIO_TOKEN, + from: process.env.TWILIO_FROM, + }, + push: { + provider: "firebase", + serviceAccount: JSON.parse(process.env.FIREBASE_SA!), + }, + }, + repository: { type: "mongodb", uri: process.env.MONGO_URI }, + templates: { engine: "handlebars", dir: "./templates" }, + enableRestApi: true, // default: true + enableWebhooks: true, // default: true + retries: { max: 3, backoff: "exponential" }, +}); +``` -| Type | Example | Directory | -| ---------- | --------------------------- | --------------- | -| Controller | `example.controller.ts` | `controllers/` | -| Service | `example.service.ts` | `services/` | -| Entity | `example.entity.ts` | `entities/` | -| Repository | `example.repository.ts` | `repositories/` | -| DTO | `create-example.dto.ts` | `dto/` | -| Guard | `jwt-auth.guard.ts` | `guards/` | -| Decorator | `current-user.decorator.ts` | `decorators/` | -| Filter | `http-exception.filter.ts` | `filters/` | -| Middleware | `logger.middleware.ts` | `middleware/` | -| Utility | `validation.utils.ts` | `utils/` | -| Config | `jwt.config.ts` | `config/` | +### `registerAsync()` β€” with ConfigService -### Code Naming +```typescript +NotificationKitModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + channels: { + email: { provider: "nodemailer", from: config.get("EMAIL_FROM") /* ... */ }, + sms: { provider: config.get("SMS_PROVIDER") /* ... */ }, + }, + repository: { type: config.get("DB_TYPE"), uri: config.get("MONGO_URI") }, + enableRestApi: config.get("NOTIF_REST_API", true), + enableWebhooks: config.get("NOTIF_WEBHOOKS", true), + }), +}); +``` -- **Classes & Interfaces**: `PascalCase` β†’ `ExampleController`, `CreateExampleDto` -- **Variables & Functions**: `camelCase` β†’ `getUserById`, `exampleList` -- **Constants**: `UPPER_SNAKE_CASE` β†’ `DEFAULT_TIMEOUT`, `MAX_RETRIES` -- **Enums**: Name `PascalCase`, values `UPPER_SNAKE_CASE` +### `registerAsync()` β€” with `useClass` / `useExisting` ```typescript -enum ExampleStatus { - ACTIVE = "ACTIVE", - INACTIVE = "INACTIVE", -} +// useClass β€” module instantiates the factory +NotificationKitModule.registerAsync({ useClass: NotificationKitConfigService }); + +// useExisting β€” reuse an already-provided factory +NotificationKitModule.registerAsync({ useExisting: NotificationKitConfigService }); +``` + +> **Rule**: All channel credentials must come from env vars or `ConfigService` β€” never hardcoded in source. Validate all options with Zod at module startup. + +> **Controller limitation**: Controllers (`enableRestApi`, `enableWebhooks`) cannot be conditionally mounted in `registerAsync` mode and are excluded. Document this clearly when advising consumers. + +--- + +## 🧩 Core Components + +### `NotificationService` (core β€” framework-free) + +The single orchestration point. Inject this in consuming apps. Never inject raw senders or repositories. + +```typescript +// Inject in your NestJS service +constructor(private readonly notifications: NotificationService) {} + +// Send a single notification +const result = await this.notifications.send({ + channel: NotificationChannel.EMAIL, + recipient: { id: 'user-1', email: 'user@example.com' }, + content: { title: 'Welcome', body: 'Hello!', templateId: 'welcome' }, + priority: NotificationPriority.HIGH, +}); + +// Batch send +const results = await this.notifications.sendBatch([...]); +``` + +**Public methods:** + +```typescript +send(dto: SendNotificationDto): Promise +sendBatch(dtos: SendNotificationDto[]): Promise +getById(id: string): Promise +getByRecipient(recipientId: string, filters?): Promise +cancel(id: string): Promise +retry(id: string): Promise ``` -### Path Aliases +### `INotificationSender` Port -Configured in `tsconfig.json`: +All channel senders implement this port. To add a new channel or provider, implement this interface in `infra/senders//`: ```typescript -"@/*" β†’ "src/*" -"@controllers/*" β†’ "src/controllers/*" -"@services/*" β†’ "src/services/*" -"@entities/*" β†’ "src/entities/*" -"@repos/*" β†’ "src/repositories/*" -"@dtos/*" β†’ "src/dto/*" -"@guards/*" β†’ "src/guards/*" -"@decorators/*" β†’ "src/decorators/*" -"@config/*" β†’ "src/config/*" -"@utils/*" β†’ "src/utils/*" +// core/ports/notification-sender.port.ts +interface INotificationSender { + readonly channel: NotificationChannel; + send(notification: Notification): Promise; + isConfigured(): boolean; +} ``` -Use aliases for cleaner imports: +### `INotificationRepository` Port + +All persistence adapters implement this. Apps never depend on Mongoose schemas directly: ```typescript -import { CreateExampleDto } from "@dtos/create-example.dto"; -import { ExampleService } from "@services/example.service"; -import { Example } from "@entities/example.entity"; +// core/ports/notification-repository.port.ts +interface INotificationRepository { + save(notification: Notification): Promise; + findById(id: string): Promise; + findByRecipient(recipientId: string, filters?): Promise; + updateStatus(id: string, status: NotificationStatus, extra?): Promise; + delete(id: string): Promise; +} ``` +### `NotificationController` (REST API) + +Mounted when `enableRestApi: true`. Provides: + +| Method | Path | Description | +| ------ | ------------------------------ | ------------------------------ | +| `POST` | `/notifications` | Send a notification | +| `POST` | `/notifications/batch` | Send multiple notifications | +| `GET` | `/notifications/:id` | Get notification by ID | +| `GET` | `/notifications/recipient/:id` | Get notifications by recipient | +| `POST` | `/notifications/:id/cancel` | Cancel a pending notification | +| `POST` | `/notifications/:id/retry` | Retry a failed notification | + +### `WebhookController` + +Mounted when `enableWebhooks: true`. Receives delivery status callbacks from providers (Twilio, Firebase, etc.) and updates notification status accordingly. Must verify provider-specific signatures. + +--- + +## πŸ”Œ Optional Provider Peer Dependencies + +All channel provider SDKs are **optional peer dependencies**. Only install what you use: + +| Channel | Provider | Peer dep | Install when... | +| ------- | ----------- | --------------------- | ---------------------------- | +| Email | Nodemailer | `nodemailer` | Using email channel | +| SMS | Twilio | `twilio` | Using Twilio SMS | +| SMS | Vonage | `@vonage/server-sdk` | Using Vonage SMS | +| SMS | AWS SNS | `@aws-sdk/client-sns` | Using AWS SNS SMS | +| Push | Firebase | `firebase-admin` | Using push notifications | +| Any | Persistence | `mongoose` | Using MongoDB repository | +| Any | Templates | `handlebars` | Using template rendering | +| Any | ID gen | `nanoid` | Using the default ID adapter | + +> **Rule for adding a new provider**: implement `INotificationSender` in `infra/senders//.sender.ts`, guard the import with a clear startup error if the peer dep is missing, and document the peer dep in JSDoc and README. + --- ## πŸ§ͺ Testing - RIGOROUS for Modules ### Coverage Target: 80%+ -**Unit Tests - MANDATORY:** +**Unit Tests β€” MANDATORY:** -- βœ… All services (business logic) -- βœ… All utilities and helpers -- βœ… Guards and decorators -- βœ… Repository methods +- βœ… `core/notification.service.ts` β€” channel routing, retry logic, status lifecycle, error handling +- βœ… All DTOs β€” Zod schema validation, edge cases, invalid inputs +- βœ… All domain errors β€” correct messages, inheritance +- βœ… Each sender adapter β€” success path, failure path, `isConfigured()` guard +- βœ… Each repository adapter β€” CRUD operations, query filters +- βœ… Template provider β€” variable substitution, missing template errors +- βœ… ID generator and datetime providers **Integration Tests:** -- βœ… Controllers (full request/response) -- βœ… Module initialization -- βœ… Database operations (with test DB or mocks) +- βœ… `NotificationKitModule.register()` β€” correct provider wiring per channel config +- βœ… `NotificationKitModule.registerAsync()` β€” factory injection, full options resolved +- βœ… `NotificationController` β€” full HTTP request/response lifecycle +- βœ… `WebhookController` β€” provider callback β†’ status update flow +- βœ… MongoDB repository β€” real schema operations (with test DB or `mongodb-memory-server`) **E2E Tests:** -- βœ… Complete flows (critical user paths) +- βœ… Send notification β†’ delivery β†’ status update (per channel) +- βœ… Retry flow (failure β†’ retry β†’ success) +- βœ… Scheduled notification lifecycle -**Test file location:** +**Test file location:** same directory as source (`*.spec.ts`) ``` -src/ - └── services/ - β”œβ”€β”€ example.service.ts - └── example.service.spec.ts ← Same directory +src/core/ + β”œβ”€β”€ notification.service.ts + └── notification.service.spec.ts + +src/infra/senders/email/ + β”œβ”€β”€ email.sender.ts + └── email.sender.spec.ts +``` + +**Mocking senders and repositories in unit tests:** + +```typescript +const mockSender: INotificationSender = { + channel: NotificationChannel.EMAIL, + send: jest.fn().mockResolvedValue({ success: true, notificationId: "n1" }), + isConfigured: jest.fn().mockReturnValue(true), +}; + +const mockRepository: INotificationRepository = { + save: jest.fn(), + findById: jest.fn(), + findByRecipient: jest.fn(), + updateStatus: jest.fn(), + delete: jest.fn(), +}; ``` **Jest Configuration:** @@ -203,9 +430,9 @@ src/ ```javascript coverageThreshold: { global: { - branches: 80, - functions: 80, - lines: 80, + branches: 80, + functions: 80, + lines: 80, statements: 80, }, } @@ -219,32 +446,49 @@ coverageThreshold: { ````typescript /** - * Creates a new example record - * @param data - The example data to create - * @returns The created example with generated ID - * @throws {BadRequestException} If data is invalid + * Sends a notification through the specified channel. + * Routes to the appropriate sender adapter, persists the notification, + * and updates its status throughout the delivery lifecycle. + * + * @param dto - Validated send notification payload + * @returns Result containing success status and provider message ID + * + * @throws {ChannelNotConfiguredError} If the channel has no configured provider + * @throws {RecipientMissingFieldError} If the recipient is missing required fields for the channel + * * @example * ```typescript - * const example = await service.create({ name: 'Test' }); + * const result = await notificationService.send({ + * channel: NotificationChannel.EMAIL, + * recipient: { id: 'user-1', email: 'user@example.com' }, + * content: { title: 'Welcome', body: 'Hello!' }, + * priority: NotificationPriority.NORMAL, + * }); * ``` */ -async create(data: CreateExampleDto): Promise +async send(dto: SendNotificationDto): Promise ```` **Required for:** -- All public functions/methods -- All exported classes -- All DTOs (with property descriptions) +- All public methods on `NotificationService` +- All port interfaces in `core/ports/` +- All exported DTOs (with per-property descriptions) +- All exported domain error classes +- Both `register()` and `registerAsync()` on `NotificationKitModule` +- All sender adapters' `send()` methods (document provider-specific behavior and peer dep) -### Swagger/OpenAPI - Always on controllers: +### Swagger/OpenAPI β€” ALWAYS on controllers: ```typescript -@ApiOperation({ summary: 'Create new example' }) -@ApiResponse({ status: 201, description: 'Created successfully', type: ExampleDto }) -@ApiResponse({ status: 400, description: 'Invalid input' }) +@ApiTags('notifications') +@ApiOperation({ summary: 'Send a notification' }) +@ApiBody({ type: SendNotificationDto }) +@ApiResponse({ status: 201, description: 'Notification queued successfully', type: NotificationResultDto }) +@ApiResponse({ status: 400, description: 'Invalid input or missing recipient field' }) +@ApiResponse({ status: 422, description: 'Channel not configured' }) @Post() -async create(@Body() dto: CreateExampleDto) { } +async send(@Body() dto: SendNotificationDto): Promise {} ``` --- @@ -253,50 +497,51 @@ async create(@Body() dto: CreateExampleDto) { } ### 1. Exportability -**Export ONLY public API (Services + DTOs + Guards + Decorators):** +**Export ONLY public API:** ```typescript -// src/index.ts - Public API -export { ExampleModule } from "./example.module"; -export { ExampleService } from "./services/example.service"; -export { CreateExampleDto, UpdateExampleDto } from "./dto"; -export { ExampleGuard } from "./guards/example.guard"; -export { ExampleDecorator } from "./decorators/example.decorator"; -export type { ExampleOptions } from "./types"; +// src/index.ts +export * from "./core"; // Types, DTOs, ports, errors, NotificationService +export * from "./infra"; // Senders, repositories, providers +export * from "./nest"; // NotificationKitModule, interfaces ``` **❌ NEVER export:** -- Entities (internal domain models) -- Repositories (infrastructure details) +- Raw SDK clients (Nodemailer transporter, Twilio client instances) +- Internal `createNotificationKitProviders()` wiring details +- Mongoose schema definitions ### 2. Configuration -**Flexible module registration:** +**All three async patterns supported:** ```typescript @Module({}) -export class ExampleModule { - static forRoot(options: ExampleModuleOptions): DynamicModule { - return { - module: ExampleModule, - providers: [{ provide: "EXAMPLE_OPTIONS", useValue: options }, ExampleService], - exports: [ExampleService], - }; +export class NotificationKitModule { + static register(options: NotificationKitModuleOptions): DynamicModule { + /* ... */ } - - static forRootAsync(options: ExampleModuleAsyncOptions): DynamicModule { - // Async configuration + static registerAsync(options: NotificationKitModuleAsyncOptions): DynamicModule { + // supports useFactory, useClass, useExisting } } ``` +**Controllers are opt-out, not opt-in:** + +```typescript +// Both default to true β€” apps must explicitly disable +NotificationKitModule.register({ enableRestApi: false, enableWebhooks: false }); +``` + ### 3. Zero Business Logic Coupling -- No hardcoded business rules -- Configurable behavior via options -- Database-agnostic (if applicable) -- Apps provide their own connections +- No hardcoded recipients, templates, credentials, or channel preferences +- All provider credentials from options (never from `process.env` directly inside the module) +- Channel senders are stateless β€” no shared mutable state between requests +- Repository is swappable β€” core service depends only on `INotificationRepository` +- Apps bring their own Mongoose connection β€” this module never creates its own DB connection --- @@ -307,34 +552,30 @@ export class ExampleModule { **1. Branch Creation:** ```bash -feature/MODULE-123-add-feature -bugfix/MODULE-456-fix-issue -refactor/MODULE-789-improve-code +feature/NOTIF-123-add-vonage-sms-sender +bugfix/NOTIF-456-fix-firebase-retry-on-token-expiry +refactor/NOTIF-789-extract-retry-logic-to-core ``` **2. Task Documentation:** Create task file at branch start: ``` -docs/tasks/active/MODULE-123-add-feature.md +docs/tasks/active/NOTIF-123-add-vonage-sms-sender.md ``` **3. On Release:** Move to archive: ``` -docs/tasks/archive/by-release/v2.0.0/MODULE-123-add-feature.md +docs/tasks/archive/by-release/v1.0.0/NOTIF-123-add-vonage-sms-sender.md ``` ### Development Workflow -**Simple changes**: +**Simple changes**: Read context β†’ Implement β†’ Update docs β†’ **Create changeset** -- Read context β†’ Implement β†’ Update docs β†’ **Create changeset** - -**Complex changes**: - -- Read context β†’ Discuss approach β†’ Implement β†’ Update docs β†’ **Create changeset** +**Complex changes**: Read context β†’ Discuss approach β†’ Implement β†’ Update docs β†’ **Create changeset** **When blocked**: @@ -347,23 +588,28 @@ docs/tasks/archive/by-release/v2.0.0/MODULE-123-add-feature.md ### Semantic Versioning (Strict) -**MAJOR** (x.0.0) - Breaking changes: +**MAJOR** (x.0.0) β€” Breaking changes: -- Changed function signatures -- Removed public methods -- Changed DTOs structure -- Changed module configuration +- Changed `NotificationService` public method signatures +- Removed or renamed fields in `SendNotificationDto` or `Notification` +- Changed `NotificationKitModuleOptions` required fields +- Renamed `register()` / `registerAsync()` or changed their call signatures +- Changed `INotificationSender` or `INotificationRepository` port contracts +- Removed a supported channel or provider -**MINOR** (0.x.0) - New features: +**MINOR** (0.x.0) β€” New features: -- New endpoints/methods -- New optional parameters -- New decorators/guards +- New channel support (e.g. WhatsApp sender) +- New optional fields in `NotificationKitModuleOptions` +- New provider for an existing channel (e.g. Vonage alongside Twilio) +- New `NotificationService` methods (additive) +- New exported utilities or decorators -**PATCH** (0.0.x) - Bug fixes: +**PATCH** (0.0.x) β€” Bug fixes: -- Internal fixes -- Performance improvements +- Provider-specific delivery fix +- Retry backoff correction +- Template rendering edge case - Documentation updates ### Changesets Workflow @@ -376,10 +622,7 @@ npx changeset **When to create a changeset:** -- βœ… New features -- βœ… Bug fixes -- βœ… Breaking changes -- βœ… Performance improvements +- βœ… New features, bug fixes, breaking changes, performance improvements - ❌ Internal refactoring (no user impact) - ❌ Documentation updates only - ❌ Test improvements only @@ -396,10 +639,10 @@ npx changeset ```markdown --- -"@ciscode/example-kit": minor +"@ciscode/notification-kit": minor --- -Added support for custom validators in ExampleService +Added Vonage SMS sender adapter as an alternative to Twilio ``` ### CHANGELOG Required @@ -407,23 +650,22 @@ Added support for custom validators in ExampleService Changesets automatically generates CHANGELOG. For manual additions: ```markdown -# Changelog - -## [2.0.0] - 2026-02-03 +## [1.0.0] - 2026-02-26 ### BREAKING CHANGES -- `create()` now requires `userId` parameter -- Removed deprecated `validateExample()` method +- `NotificationService.send()` now requires `priority` field in `SendNotificationDto` +- Removed `createDefaultNotificationService()` β€” use `NotificationKitModule.register()` instead ### Added -- New `ExampleGuard` for route protection -- Support for async configuration +- Vonage SMS sender adapter +- `sendBatch()` method on `NotificationService` +- In-memory repository for testing and lightweight usage ### Fixed -- Fixed validation edge case +- Firebase push sender now correctly retries on token expiry (401) ``` --- @@ -432,45 +674,44 @@ Changesets automatically generates CHANGELOG. For manual additions: **ALWAYS:** -- βœ… Input validation on all DTOs (class-validator) -- βœ… JWT secret from env (never hardcoded) -- βœ… Rate limiting on public endpoints -- βœ… No secrets in code -- βœ… Sanitize error messages (no stack traces in production) - -**Example:** +- βœ… Validate all DTOs with Zod at module boundary +- βœ… All provider credentials from env vars β€” never hardcoded +- βœ… Sanitize notification content before logging β€” never log full `templateVars` (may contain PII) +- βœ… Webhook endpoints must verify provider signatures (e.g. `X-Twilio-Signature`) +- βœ… Rate-limit the REST API endpoints in production (document this requirement for consumers) +- βœ… Recipient `metadata` must never appear in error messages or stack traces ```typescript -export class CreateExampleDto { - @IsString() - @MinLength(3) - @MaxLength(50) - name: string; - - @IsEmail() - email: string; -} +// ❌ WRONG β€” logs PII from templateVars +this.logger.error("Template render failed", { notification }); + +// βœ… CORRECT β€” log only safe identifiers +this.logger.error("Template render failed", { + notificationId: notification.id, + channel: notification.channel, +}); ``` --- -## 🚫 Restrictions - Require Approval +## 🚫 Restrictions β€” Require Approval **NEVER without approval:** -- Breaking changes to public API -- Changing exported DTOs/interfaces -- Removing exported functions -- Major dependency upgrades -- Security-related changes +- Breaking changes to `NotificationService` public methods +- Removing or renaming fields in `SendNotificationDto`, `Notification`, or `NotificationResult` +- Changing `INotificationSender` or `INotificationRepository` port contracts +- Removing a supported channel or provider adapter +- Renaming `register()` / `registerAsync()` or their option shapes +- Security-related changes (webhook signature verification, credential handling) **CAN do autonomously:** -- Bug fixes (no breaking changes) -- Internal refactoring -- Adding new features (non-breaking) -- Test improvements -- Documentation updates +- Bug fixes (non-breaking) +- New optional `NotificationKitModuleOptions` fields +- New sender adapter for an existing channel (e.g. AWS SES alongside Nodemailer) +- Internal refactoring within a single layer (no public API or port contract change) +- Test and documentation improvements --- @@ -481,39 +722,42 @@ Before publishing: - [ ] All tests passing (100% of test suite) - [ ] Coverage >= 80% - [ ] No ESLint warnings (`--max-warnings=0`) -- [ ] TypeScript strict mode passing +- [ ] TypeScript strict mode passing (`tsc --noEmit`) +- [ ] `npm run build` succeeds β€” both `.mjs` and `.cjs` outputs in `dist/` - [ ] All public APIs documented (JSDoc) -- [ ] README updated with examples +- [ ] All new `NotificationKitModuleOptions` fields documented in README +- [ ] Optional peer deps documented (which to install for which channel) - [ ] Changeset created -- [ ] Breaking changes highlighted -- [ ] Integration tested with sample app +- [ ] Breaking changes highlighted in changeset +- [ ] Integration tested via `npm link` in a real consuming NestJS app --- ## πŸ”„ Development Workflow -### Working on Module: +### Working on the Module: -1. Clone module repo -2. Create branch: `feature/TASK-123-description` from `develop` +1. Clone the repo +2. Create branch: `feature/NOTIF-123-description` from `develop` 3. Implement with tests 4. **Create changeset**: `npx changeset` 5. Verify checklist 6. Create PR β†’ `develop` -### Testing in App: +### Testing in a Consuming App: ```bash -# In module +# In notification-kit +npm run build npm link -# In app -cd ~/comptaleyes/backend -npm link @ciscode/example-kit +# In your NestJS app +cd ~/ciscode/backend +npm link @ciscode/notification-kit # Develop and test # Unlink when done -npm unlink @ciscode/example-kit +npm unlink @ciscode/notification-kit ``` --- @@ -523,18 +767,35 @@ npm unlink @ciscode/example-kit - ESLint `--max-warnings=0` - Prettier formatting - TypeScript strict mode -- FP for logic, OOP for structure -- Dependency injection via constructor - -**Example:** +- Pure functions in `core/` (no side effects, no SDK calls) +- OOP classes for NestJS providers and sender/repository adapters +- Dependency injection via constructor β€” never property-based `@Inject()` +- Sender adapters are stateless β€” no mutable instance variables after construction ```typescript +// βœ… Correct β€” constructor injection, stateless sender @Injectable() -export class ExampleService { +export class EmailSender implements INotificationSender { + readonly channel = NotificationChannel.EMAIL; + constructor( - private readonly repo: ExampleRepository, - private readonly logger: LoggerService, + @Inject(NOTIFICATION_KIT_OPTIONS) + private readonly options: NotificationKitModuleOptions, ) {} + + async send(notification: Notification): Promise { + /* ... */ + } + isConfigured(): boolean { + return !!this.options.channels?.email; + } +} + +// ❌ Wrong β€” property injection, mutable state +@Injectable() +export class EmailSender { + @Inject(NOTIFICATION_KIT_OPTIONS) private options: NotificationKitModuleOptions; + private transporter: any; // mutated after construction ← FORBIDDEN } ``` @@ -542,24 +803,34 @@ export class ExampleService { ## πŸ› Error Handling -**Custom domain errors:** +**Custom domain errors β€” ALWAYS in `core/errors/`:** ```typescript -export class ExampleNotFoundError extends Error { +export class ChannelNotConfiguredError extends Error { + constructor(channel: NotificationChannel) { + super( + `Channel "${channel}" is not configured. Did you pass options for it in NotificationKitModule.register()?`, + ); + this.name = "ChannelNotConfiguredError"; + } +} + +export class NotificationNotFoundError extends Error { constructor(id: string) { - super(`Example ${id} not found`); - this.name = "ExampleNotFoundError"; + super(`Notification "${id}" not found`); + this.name = "NotificationNotFoundError"; } } ``` -**Structured logging:** +**Structured logging β€” safe identifiers only:** ```typescript -this.logger.error("Operation failed", { - exampleId: id, - reason: "validation_error", - timestamp: new Date().toISOString(), +this.logger.error("Notification delivery failed", { + notificationId: notification.id, + channel: notification.channel, + provider: "twilio", + attempt: notification.retryCount, }); ``` @@ -568,16 +839,18 @@ this.logger.error("Operation failed", { ```typescript // ❌ WRONG try { - await operation(); -} catch (error) { - // Silent failure + await sender.send(notification); +} catch { + // silent } // βœ… CORRECT try { - await operation(); + await sender.send(notification); } catch (error) { - this.logger.error("Operation failed", { error }); + await this.repository.updateStatus(notification.id, NotificationStatus.FAILED, { + error: (error as Error).message, + }); throw error; } ``` @@ -587,9 +860,10 @@ try { ## πŸ’¬ Communication Style - Brief and direct -- Focus on results -- Module-specific context -- Highlight breaking changes immediately +- Reference the correct layer (`core`, `infra`, `nest`) when discussing changes +- Always name the channel and provider when discussing sender-related changes +- Flag breaking changes immediately β€” even suspected ones +- This module is consumed by multiple services β€” when in doubt about impact, ask --- @@ -602,12 +876,28 @@ try { 3. Complete documentation 4. Strict versioning 5. Breaking changes = MAJOR bump + changeset -6. Zero app coupling -7. Configurable behavior - -**When in doubt:** Ask, don't assume. Modules impact multiple projects. +6. Zero app coupling β€” no hardcoded credentials, recipients, or templates +7. Configurable behavior via `NotificationKitModuleOptions` + +**Layer ownership β€” quick reference:** + +| Concern | Owner | +| ---------------------------- | ---------------------------------- | +| Domain types & enums | `src/core/types.ts` | +| DTOs & Zod validation | `src/core/dtos/` | +| Port interfaces | `src/core/ports/` | +| Orchestration logic | `src/core/notification.service.ts` | +| Domain errors | `src/core/errors/` | +| Channel sender adapters | `src/infra/senders//` | +| Persistence adapters | `src/infra/repositories/` | +| Utility adapters | `src/infra/providers/` | +| NestJS DI, module, providers | `src/nest/` | +| REST API & webhook endpoints | `src/nest/controllers/` | +| All public exports | `src/index.ts` | + +**When in doubt:** Ask, don't assume. This module delivers notifications across production services. --- -_Last Updated: February 3, 2026_ -_Version: 2.0.0_ +_Last Updated: February 26, 2026_ +_Version: 1.0.0_ From ddc193485c17d84efad424d953e7bae9a4768407 Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Tue, 3 Mar 2026 10:22:55 +0000 Subject: [PATCH 05/13] fix: resolve SonarQube security hotspots and workflow configuration - Replace GitHub Actions version tags with full commit SHA hashes (v6 -> fd88b7d, v1 -> d304d05) - Fix SONAR_PROJECT_KEY from LoggingKit to NotificationKit - Add 'main' branch to PR trigger list for release-check workflow - Resolves 2 security hotspots (githubactions:S7637) for supply chain security --- .github/workflows/release-check.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 1a05af2..45b37b3 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -2,7 +2,7 @@ name: CI - Release Check on: pull_request: - branches: [master] + branches: [master, main] workflow_dispatch: inputs: sonar: @@ -28,7 +28,7 @@ jobs: env: SONAR_HOST_URL: "https://sonarcloud.io" SONAR_ORGANIZATION: "ciscode" - SONAR_PROJECT_KEY: "CISCODE-MA_LoggingKit" + SONAR_PROJECT_KEY: "CISCODE-MA_NotificationKit" steps: - name: Checkout @@ -62,7 +62,7 @@ jobs: - name: SonarCloud Scan if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sonar == 'true' }} - uses: SonarSource/sonarqube-scan-action@v6 + uses: SonarSource/sonarqube-scan-action@fd88b7d7ccbaefd23d8f36f73b59db7a3d246602 # v6 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }} @@ -76,7 +76,7 @@ jobs: - name: SonarCloud Quality Gate if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sonar == 'true' }} - uses: SonarSource/sonarqube-quality-gate-action@v1 + uses: SonarSource/sonarqube-quality-gate-action@d304d050d930b02a896b0f85935344f023928496 # v1 timeout-minutes: 10 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} From c05316b09488ee1c4704ba5456557cb1488d6a4b Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Tue, 3 Mar 2026 10:25:14 +0000 Subject: [PATCH 06/13] fix: resolve NotificationKit TypeScript type errors - Add explicit NotificationDocument type annotations to map callbacks - Fix mapToRecord signature to accept any type (handles both Map and Record) - Install mongoose as dev dependency for type checking - Fixes pre-push typecheck failures --- package-lock.json | 212 ++++++++++++++++++ package.json | 1 + .../mongoose/mongoose.repository.ts | 9 +- 3 files changed, 218 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 424c694..734bbbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "husky": "^9.1.7", "jest": "^29.7.0", "lint-staged": "^16.2.7", + "mongoose": "^9.2.3", "prettier": "^3.4.2", "ts-jest": "^29.2.5", "tsup": "^8.3.5", @@ -2481,6 +2482,16 @@ "node": ">= 4.0.0" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", + "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@nestjs/common": { "version": "11.1.10", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.10.tgz", @@ -3409,6 +3420,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -4283,6 +4311,16 @@ "node-int64": "^0.4.0" } }, + "node_modules/bson": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", + "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -7966,6 +8004,16 @@ "node": ">=6" } }, + "node_modules/kareem": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.2.0.tgz", + "integrity": "sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8289,6 +8337,13 @@ "node": ">= 0.8" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "dev": true, + "license": "MIT" + }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -8449,6 +8504,109 @@ "ufo": "^1.6.1" } }, + "node_modules/mongodb": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.0.0.tgz", + "integrity": "sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.0.0", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongoose": { + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.2.3.tgz", + "integrity": "sha512-4XFKKkXUOsdY+p07eJyio4mk0rzZOT4n5r5tLqZNeRZ/IsS68vS8Szw8uShX4p7S687XGGc+MFAp+6K1OIN0aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "kareem": "3.2.0", + "mongodb": "~7.0", + "mpath": "0.9.0", + "mquery": "6.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz", + "integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -10037,6 +10195,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -10113,6 +10278,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/spawndamnit": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spawndamnit/-/spawndamnit-3.0.1.tgz", @@ -10570,6 +10745,19 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -11146,6 +11334,30 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 8e8f357..d516f7a 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "husky": "^9.1.7", "jest": "^29.7.0", "lint-staged": "^16.2.7", + "mongoose": "^9.2.3", "prettier": "^3.4.2", "ts-jest": "^29.2.5", "tsup": "^8.3.5", diff --git a/src/infra/repositories/mongoose/mongoose.repository.ts b/src/infra/repositories/mongoose/mongoose.repository.ts index 7f47825..06d36fa 100644 --- a/src/infra/repositories/mongoose/mongoose.repository.ts +++ b/src/infra/repositories/mongoose/mongoose.repository.ts @@ -118,7 +118,7 @@ export class MongooseNotificationRepository implements INotificationRepository { const docs = await query.exec(); - return docs.map((doc) => this.documentToNotification(doc)); + return docs.map((doc: NotificationDocument) => this.documentToNotification(doc)); } async update(_id: string, _updates: Partial): Promise { @@ -207,7 +207,7 @@ export class MongooseNotificationRepository implements INotificationRepository { .limit(_limit) .exec(); - return docs.map((doc) => this.documentToNotification(doc)); + return docs.map((doc: NotificationDocument) => this.documentToNotification(doc)); } /** @@ -251,10 +251,11 @@ export class MongooseNotificationRepository implements INotificationRepository { /** * Convert Mongoose Map to plain object */ - private mapToRecord(map: Map | any): Record { + private mapToRecord(map: any): Record { if (map instanceof Map) { return Object.fromEntries(map); } - return map; + // If it's already an object, return as-is + return map as Record; } } From b2a9300ebf92bfeaf76e8d6e8e6df00aa82831f6 Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Tue, 3 Mar 2026 10:46:33 +0000 Subject: [PATCH 07/13] ops: added dependabot & sonar instructions --- .github/dependabot.yml | 34 ++++++++++++++++++ .github/sonarqube_mcp.instructions.md | 50 +++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/sonarqube_mcp.instructions.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..44e8a1a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,34 @@ +version: 2 +updates: + # npm dependencies + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + day: monday + time: "03:00" + open-pull-requests-limit: 5 + assignees: + - CISCODE-MA/cloud-devops + labels: + - "dependencies" + - "npm" + commit-message: + prefix: "chore(deps)" + include: "scope" + rebase-strategy: auto + + # GitHub Actions + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: sunday + time: "03:00" + assignees: + - CISCODE-MA/cloud-devops + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "ci(deps)" diff --git a/.github/sonarqube_mcp.instructions.md b/.github/sonarqube_mcp.instructions.md new file mode 100644 index 0000000..61523c0 --- /dev/null +++ b/.github/sonarqube_mcp.instructions.md @@ -0,0 +1,50 @@ +--- +applyTo: "**/*" +--- + +These are some guidelines when using the SonarQube MCP server. + +# Important Tool Guidelines + +## Basic usage + +- **IMPORTANT**: After you finish generating or modifying any code files at the very end of the task, you MUST call the `analyze_file_list` tool (if it exists) to analyze the files you created or modified. +- **IMPORTANT**: When starting a new task, you MUST disable automatic analysis with the `toggle_automatic_analysis` tool if it exists. +- **IMPORTANT**: When you are done generating code at the very end of the task, you MUST re-enable automatic analysis with the `toggle_automatic_analysis` tool if it exists. + +## Project Keys + +- When a user mentions a project key, use `search_my_sonarqube_projects` first to find the exact project key +- Don't guess project keys - always look them up + +## Code Language Detection + +- When analyzing code snippets, try to detect the programming language from the code syntax +- If unclear, ask the user or make an educated guess based on syntax + +## Branch and Pull Request Context + +- Many operations support branch-specific analysis +- If user mentions working on a feature branch, include the branch parameter + +## Code Issues and Violations + +- After fixing issues, do not attempt to verify them using `search_sonar_issues_in_projects`, as the server will not yet reflect the updates + +# Common Troubleshooting + +## Authentication Issues + +- SonarQube requires USER tokens (not project tokens) +- When the error `SonarQube answered with Not authorized` occurs, verify the token type + +## Project Not Found + +- Use `search_my_sonarqube_projects` to find available projects +- Verify project key spelling and format + +## Code Analysis Issues + +- Ensure programming language is correctly specified +- Remind users that snippet analysis doesn't replace full project scans +- Provide full file content for better analysis results From 66d76b82485348b7e0aef13233c00488300e9135 Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Tue, 3 Mar 2026 20:56:20 +0000 Subject: [PATCH 08/13] chore: added comprehensive changesets for release automation --- .changeset/notificationkit_71368.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/notificationkit_71368.md diff --git a/.changeset/notificationkit_71368.md b/.changeset/notificationkit_71368.md new file mode 100644 index 0000000..539278a --- /dev/null +++ b/.changeset/notificationkit_71368.md @@ -0,0 +1,13 @@ +--- +"@ciscode/notification-kit": minor +--- + +## Summary + +First official release: Added Dependabot automation and SonarQube MCP integration instructions + +## Changes + +- Updated package configuration and workflows +- Enhanced code quality and automation tooling +- Improved CI/CD integration and monitoring capabilities From 6bdfd2244d6964d48c80283279e44c82b85884f5 Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Tue, 3 Mar 2026 21:48:37 +0000 Subject: [PATCH 09/13] docs: add standardized instruction files structure - Add comprehensive instruction files in .github/instructions/ - Includes copilot, testing, bugfix, features, general guidelines - Standardize documentation across all repositories --- .github/instructions/bugfix.instructions.md | 292 ++++++ .github/instructions/copilot-instructions.md | 903 ++++++++++++++++++ .github/instructions/features.instructions.md | 354 +++++++ .github/instructions/general.instructions.md | 239 +++++ .../sonarqube_mcp.instructions.md | 50 + .github/instructions/testing.instructions.md | 349 +++++++ 6 files changed, 2187 insertions(+) create mode 100644 .github/instructions/bugfix.instructions.md create mode 100644 .github/instructions/copilot-instructions.md create mode 100644 .github/instructions/features.instructions.md create mode 100644 .github/instructions/general.instructions.md create mode 100644 .github/instructions/sonarqube_mcp.instructions.md create mode 100644 .github/instructions/testing.instructions.md diff --git a/.github/instructions/bugfix.instructions.md b/.github/instructions/bugfix.instructions.md new file mode 100644 index 0000000..5b75679 --- /dev/null +++ b/.github/instructions/bugfix.instructions.md @@ -0,0 +1,292 @@ +# Bugfix Instructions - Kit Module + +> **Last Updated**: February 2026 + +--- + +## πŸ” Bug Investigation Process + +### Phase 1: Reproduce + +**Before writing any code:** + +1. **Understand the issue** - Read bug report carefully +2. **Reproduce locally** - Create minimal test case +3. **Verify it's a bug** - Not expected behavior or user error +4. **Check documentation** - Is feature documented correctly? + +**Create failing test FIRST:** + +```typescript +describe("Bug: Service returns null unexpectedly", () => { + it("should return data when ID exists", async () => { + mockRepository.findById.mockResolvedValue(mockData); + + // This SHOULD pass but currently FAILS + const result = await service.findById("existing-id"); + expect(result).toBeDefined(); + }); +}); +``` + +### Phase 2: Identify Root Cause + +**Investigation tools:** + +- **Logging** - Add temporary debug logs +- **Debugger** - Use VS Code debugger +- **Unit tests** - Isolate failing component +- **Git blame** - Check when code was added + +```typescript +// Add debug logging +this.logger.debug(`Input: ${JSON.stringify(input)}`, "ServiceName"); +this.logger.debug(`Result: ${JSON.stringify(result)}`, "ServiceName"); +``` + +### Phase 3: Understand Impact + +**Critical questions:** + +- How many users affected? +- Is this a security issue? (Priority: CRITICAL) +- Is there a workaround? +- Does this affect other features? +- What version introduced this? + +--- + +## πŸ› Common Bug Categories + +### 1. Database Issues + +| Bug Type | Symptoms | Solution | +| ----------------------- | --------------------- | ----------------------------------------- | +| **Query returns null** | Expected data missing | Check populate, fix query filter | +| **Duplicate key error** | Cannot create record | Add validation, handle unique constraints | +| **Population error** | Relations missing | Fix populate path, check ref | + +**Example fix:** + +```typescript +// ❌ BUG - Missing populate +async findUserWithRoles(id: string) { + return this.userModel.findById(id); // roles = [ObjectId(...)] +} + +// βœ… FIX - Populate relations +async findUserWithRoles(id: string) { + return this.userModel + .findById(id) + .populate('roles') // roles = [{ name: 'admin', ... }] + .lean(); +} +``` + +### 2. Async/Promise Issues + +| Bug Type | Symptoms | Solution | +| ----------------------- | --------------------- | --------------------- | +| **Missing await** | Unexpected Promise | Add await keyword | +| **Unhandled rejection** | Crash/silent failure | Add try-catch | +| **Race condition** | Intermittent failures | Use proper async flow | + +**Example fix:** + +```typescript +// ❌ BUG - Missing await +async processItems(items: Item[]) { + items.forEach(item => this.process(item)); // Fire and forget! +} + +// βœ… FIX - Proper async handling +async processItems(items: Item[]) { + await Promise.all(items.map(item => this.process(item))); +} +``` + +### 3. Validation Errors + +| Bug Type | Symptoms | Solution | +| ---------------------- | ---------------------- | -------------------- | +| **Missing validation** | Invalid data accepted | Add DTO validation | +| **Wrong type** | Type errors at runtime | Fix TypeScript types | +| **Edge case** | Crashes on null/undef | Add null checks | + +**Example fix:** + +```typescript +// ❌ BUG - No null check +function getName(user: User): string { + return user.profile.name; // Crashes if profile is null +} + +// βœ… FIX - Defensive programming +function getName(user: User | null): string { + return user?.profile?.name ?? "Unknown"; +} +``` + +### 4. Guard/Auth Issues + +| Bug Type | Symptoms | Solution | +| ----------------------- | -------------------------- | -------------------- | +| **Unauthorized access** | Wrong users can access | Fix guard logic | +| **Token rejection** | Valid tokens rejected | Fix token validation | +| **Role check fails** | Permission check incorrect | Fix role comparison | + +**Example fix:** + +```typescript +// ❌ BUG - Comparing ObjectId to string +if (user.roles.includes(requiredRoleId)) { + // Always false + return true; +} + +// βœ… FIX - Convert to strings +const roleIds = user.roles.map((r) => r.toString()); +if (roleIds.includes(requiredRoleId)) { + return true; +} +``` + +### 5. Error Handling + +| Bug Type | Symptoms | Solution | +| -------------------- | --------------------- | ---------------------------- | +| **Swallowed errors** | Silent failures | Throw or log errors | +| **Wrong error type** | Incorrect HTTP status | Use correct NestJS exception | +| **Missing logs** | Can't debug issues | Add structured logging | + +**Example fix:** + +```typescript +// ❌ BUG - Error swallowed +async sendEmail(email: string) { + try { + await this.mail.send(email); + } catch (error) { + console.error(error); // ❌ Swallows error! + } +} + +// βœ… FIX - Proper error handling +async sendEmail(email: string) { + try { + await this.mail.send(email); + this.logger.log(`Email sent to ${email}`, 'MailService'); + } catch (error) { + this.logger.error( + `Failed to send email: ${error.message}`, + error.stack, + 'MailService' + ); + throw new InternalServerErrorException('Email service unavailable'); + } +} +``` + +--- + +## πŸ”§ Fix Implementation Process + +### 1. Write Failing Test + +```typescript +// Test that currently fails +it("should fix the bug", async () => { + const result = await service.buggyMethod(); + expect(result).toBe(expectedValue); +}); +``` + +### 2. Implement Fix + +```typescript +// Fix the code +async buggyMethod() { + // New corrected implementation + return correctValue; +} +``` + +### 3. Verify Test Passes + +```bash +npm test -- buggy-service.spec.ts +``` + +### 4. Test Edge Cases + +```typescript +it("should handle edge case", async () => { + const result = await service.buggyMethod(edgeCaseInput); + expect(result).toBeDefined(); +}); +``` + +### 5. Update Documentation + +```typescript +/** + * Method that was buggy + * + * @fixed Version 1.2.3 - Fixed null pointer issue + * @param input - The input parameter + * @returns The expected result + */ +async buggyMethod(input: string): Promise +``` + +--- + +## ⚠️ Common Gotchas + +### 1. Timezone Issues + +```typescript +// ❌ Potential bug - Timezone-dependent +const date = new Date(); + +// βœ… Better - Use UTC +const date = new Date().toISOString(); +``` + +### 2. Floating Point Comparison + +```typescript +// ❌ Bug - Direct comparison +if (price === 10.2) { +} // Might fail due to precision + +// βœ… Fix - Use tolerance +if (Math.abs(price - 10.2) < 0.01) { +} +``` + +### 3. MongoDB ObjectId Comparison + +```typescript +// ❌ Bug - Comparing objects +if (user._id === userId) { +} // Always false + +// βœ… Fix - Convert to string +if (user._id.toString() === userId) { +} +``` + +--- + +## πŸ“‹ Bugfix Checklist + +- [ ] Bug reproduced locally +- [ ] Failing test created +- [ ] Root cause identified +- [ ] Fix implemented +- [ ] All tests pass +- [ ] Edge cases tested +- [ ] Documentation updated +- [ ] CHANGELOG updated +- [ ] No regression (other features still work) diff --git a/.github/instructions/copilot-instructions.md b/.github/instructions/copilot-instructions.md new file mode 100644 index 0000000..14512f5 --- /dev/null +++ b/.github/instructions/copilot-instructions.md @@ -0,0 +1,903 @@ +# Copilot Instructions - @ciscode/notification-kit + +> **Purpose**: Universal NestJS notification library supporting multi-channel delivery (Email, SMS, Push, In-App, Webhook) with pluggable provider backends, template support, persistence, and a built-in REST + Webhook API. + +--- + +## 🎯 Package Overview + +**Package**: `@ciscode/notification-kit` +**Type**: Backend NestJS Notification Module +**Purpose**: Centralized, multi-channel notification delivery with pluggable providers, retry logic, status tracking, and scheduling β€” usable across all `@ciscode/*` services + +### This Package Provides: + +- CSR (Controller-Service-Repository) architecture with Clean Architecture ports +- `NotificationKitModule` β€” global NestJS dynamic module (`register` / `registerAsync`) +- `NotificationService` β€” injectable orchestration service (core, framework-free) +- `NotificationController` β€” REST API for sending and querying notifications +- `WebhookController` β€” inbound webhook receiver for provider delivery callbacks +- Channel senders: **Email** (Nodemailer), **SMS** (Twilio / Vonage / AWS SNS), **Push** (Firebase), **In-App**, **Webhook** +- Repository adapters: **MongoDB** (Mongoose) and **In-Memory** +- Template rendering via Handlebars +- Zod-validated configuration +- Changesets for version management +- Husky + lint-staged for code quality +- Copilot-friendly development guidelines + +--- + +## πŸ—οΈ Module Architecture + +**NotificationKit uses CSR (Controller-Service-Repository) + Ports & Adapters for maximum reusability and provider interchangeability.** + +> **WHY CSR + Ports?** Reusable notification libraries must support multiple providers without coupling business logic to any specific SDK. Ports (interfaces) in `core/` define the contracts; adapters in `infra/` implement them. Apps choose which adapters to wire. + +``` +src/ + β”œβ”€β”€ index.ts # PUBLIC API β€” all exports go through here + β”‚ + β”œβ”€β”€ core/ # βœ… Framework-FREE (no NestJS imports) + β”‚ β”œβ”€β”€ index.ts + β”‚ β”œβ”€β”€ types.ts # Domain entities & enums + β”‚ β”œβ”€β”€ dtos/ # Input/output contracts (Zod-validated) + β”‚ β”œβ”€β”€ ports/ # Abstractions (interfaces the infra implements) + β”‚ β”‚ β”œβ”€β”€ notification-sender.port.ts # INotificationSender + β”‚ β”‚ β”œβ”€β”€ notification-repository.port.ts # INotificationRepository + β”‚ β”‚ └── (template, event, id, datetime ports) + β”‚ β”œβ”€β”€ errors/ # Domain errors + β”‚ └── notification.service.ts # Core orchestration logic (framework-free) + β”‚ + β”œβ”€β”€ infra/ # Concrete adapter implementations + β”‚ β”œβ”€β”€ index.ts + β”‚ β”œβ”€β”€ senders/ # Channel sender adapters + β”‚ β”‚ β”œβ”€β”€ email/ # Nodemailer adapter + β”‚ β”‚ β”œβ”€β”€ sms/ # Twilio / Vonage / AWS SNS adapters + β”‚ β”‚ β”œβ”€β”€ push/ # Firebase adapter + β”‚ β”‚ β”œβ”€β”€ in-app/ # In-app adapter + β”‚ β”‚ └── webhook/ # Outbound webhook adapter + β”‚ β”œβ”€β”€ repositories/ # Persistence adapters + β”‚ β”‚ β”œβ”€β”€ mongodb/ # Mongoose adapter + β”‚ β”‚ └── in-memory/ # In-memory adapter (testing / simple usage) + β”‚ └── providers/ # Utility adapters + β”‚ β”œβ”€β”€ id-generator/ # nanoid adapter + β”‚ β”œβ”€β”€ datetime/ # Date/time utilities + β”‚ β”œβ”€β”€ template/ # Handlebars adapter + β”‚ └── events/ # Event bus adapter + β”‚ + └── nest/ # NestJS integration layer + β”œβ”€β”€ index.ts + β”œβ”€β”€ module.ts # NotificationKitModule + β”œβ”€β”€ interfaces.ts # NotificationKitModuleOptions, AsyncOptions, Factory + β”œβ”€β”€ constants.ts # NOTIFICATION_KIT_OPTIONS token + β”œβ”€β”€ providers.ts # createNotificationKitProviders() factory + └── controllers/ + β”œβ”€β”€ notification.controller.ts # REST API (enable via enableRestApi) + └── webhook.controller.ts # Inbound webhooks (enable via enableWebhooks) +``` + +**Responsibility Layers:** + +| Layer | Responsibility | Examples | +| ----------------- | ---------------------------------------------------------- | ----------------------------------------------------------- | +| **Controllers** | HTTP handling, REST API, inbound webhook receivers | `NotificationController`, `WebhookController` | +| **Core Service** | Orchestration, channel routing, retry, status lifecycle | `notification.service.ts` | +| **DTOs** | Input validation, API contracts (Zod) | `SendNotificationDto`, `NotificationQueryDto` | +| **Ports** | Abstractions β€” what `core/` depends on | `INotificationSender`, `INotificationRepository` | +| **Senders** | Channel delivery β€” implement `INotificationSender` | `EmailSender`, `SmsSender`, `PushSender` | +| **Repositories** | Persistence β€” implement `INotificationRepository` | `MongoNotificationRepository`, `InMemoryRepository` | +| **Providers** | Cross-cutting utilities | `HandlebarsTemplateProvider`, `NanoidGenerator` | +| **Domain Types** | Entities, enums, value objects (immutable, framework-free) | `Notification`, `NotificationChannel`, `NotificationStatus` | +| **Domain Errors** | Typed, named error classes | `ChannelNotConfiguredError`, `NotificationNotFoundError` | + +### Layer Import Rules β€” STRICTLY ENFORCED + +| Layer | Can import from | Cannot import from | +| ------- | ---------------------- | ------------------ | +| `core` | Nothing internal | `infra`, `nest` | +| `infra` | `core` (ports & types) | `nest` | +| `nest` | `core`, `infra` | β€” | + +> **The golden rule**: `core/` must compile with zero NestJS or provider SDK imports. If you're adding a NestJS decorator or importing `nodemailer` inside `core/`, it's in the wrong layer. + +--- + +## πŸ“ Naming Conventions + +### Files + +**Pattern**: `kebab-case` + suffix + +| Type | Example | Directory | +| ---------------- | ---------------------------------- | --------------------------------- | +| Module | `module.ts` | `src/nest/` | +| Controller | `notification.controller.ts` | `src/nest/controllers/` | +| Core Service | `notification.service.ts` | `src/core/` | +| Port interface | `notification-sender.port.ts` | `src/core/ports/` | +| DTO | `send-notification.dto.ts` | `src/core/dtos/` | +| Domain Error | `notification-not-found.error.ts` | `src/core/errors/` | +| Sender adapter | `email.sender.ts` | `src/infra/senders/email/` | +| Repository | `mongo-notification.repository.ts` | `src/infra/repositories/mongodb/` | +| Utility provider | `handlebars-template.provider.ts` | `src/infra/providers/template/` | +| Constants | `constants.ts` | `src/nest/` | + +### Code Naming + +- **Classes & Interfaces**: `PascalCase` β†’ `NotificationService`, `INotificationSender`, `SendNotificationDto` +- **Variables & functions**: `camelCase` β†’ `sendNotification`, `buildProviders` +- **Constants / DI tokens**: `UPPER_SNAKE_CASE` β†’ `NOTIFICATION_KIT_OPTIONS`, `NOTIFICATION_SENDER`, `NOTIFICATION_REPOSITORY` +- **Enums**: Name `PascalCase`, values match protocol strings + +```typescript +// βœ… Correct enum definitions +enum NotificationChannel { + EMAIL = "email", + SMS = "sms", + PUSH = "push", + IN_APP = "in_app", + WEBHOOK = "webhook", +} + +enum NotificationStatus { + PENDING = "pending", + QUEUED = "queued", + SENDING = "sending", + SENT = "sent", + DELIVERED = "delivered", + FAILED = "failed", + CANCELLED = "cancelled", +} +``` + +### Path Aliases (`tsconfig.json`) + +```typescript +"@/*" β†’ "src/*" +"@core/*" β†’ "src/core/*" +"@infra/*" β†’ "src/infra/*" +"@nest/*" β†’ "src/nest/*" +``` + +Use aliases for cleaner imports: + +```typescript +import { NotificationService } from "@core/notification.service"; +import { INotificationSender } from "@core/ports/notification-sender.port"; +import { SendNotificationDto } from "@core/dtos/send-notification.dto"; +import { EmailSender } from "@infra/senders/email/email.sender"; +``` + +--- + +## πŸ“¦ Public API β€” `src/index.ts` + +```typescript +// βœ… All exports go through here β€” never import from deep paths in consuming apps +export * from "./core"; // Types, DTOs, ports, errors, NotificationService +export * from "./infra"; // Senders, repositories, utility providers +export * from "./nest"; // NotificationKitModule, interfaces, constants +``` + +**What consuming apps should use:** + +```typescript +import { + NotificationKitModule, + NotificationService, + SendNotificationDto, + NotificationChannel, + NotificationStatus, + NotificationPriority, + type Notification, + type NotificationResult, + type INotificationSender, // for custom adapter implementations + type INotificationRepository, // for custom adapter implementations +} from "@ciscode/notification-kit"; +``` + +**❌ NEVER export:** + +- Internal provider wiring (`createNotificationKitProviders` internals) +- Raw SDK instances (Nodemailer transporter, Twilio client, Firebase app) +- Mongoose schema definitions (infrastructure details) + +--- + +## βš™οΈ Module Registration + +### `register()` β€” sync + +```typescript +NotificationKitModule.register({ + channels: { + email: { + provider: "nodemailer", + from: "no-reply@ciscode.com", + smtp: { host: "smtp.example.com", port: 587, auth: { user: "...", pass: "..." } }, + }, + sms: { + provider: "twilio", + accountSid: process.env.TWILIO_SID, + authToken: process.env.TWILIO_TOKEN, + from: process.env.TWILIO_FROM, + }, + push: { + provider: "firebase", + serviceAccount: JSON.parse(process.env.FIREBASE_SA!), + }, + }, + repository: { type: "mongodb", uri: process.env.MONGO_URI }, + templates: { engine: "handlebars", dir: "./templates" }, + enableRestApi: true, // default: true + enableWebhooks: true, // default: true + retries: { max: 3, backoff: "exponential" }, +}); +``` + +### `registerAsync()` β€” with ConfigService + +```typescript +NotificationKitModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + channels: { + email: { provider: "nodemailer", from: config.get("EMAIL_FROM") /* ... */ }, + sms: { provider: config.get("SMS_PROVIDER") /* ... */ }, + }, + repository: { type: config.get("DB_TYPE"), uri: config.get("MONGO_URI") }, + enableRestApi: config.get("NOTIF_REST_API", true), + enableWebhooks: config.get("NOTIF_WEBHOOKS", true), + }), +}); +``` + +### `registerAsync()` β€” with `useClass` / `useExisting` + +```typescript +// useClass β€” module instantiates the factory +NotificationKitModule.registerAsync({ useClass: NotificationKitConfigService }); + +// useExisting β€” reuse an already-provided factory +NotificationKitModule.registerAsync({ useExisting: NotificationKitConfigService }); +``` + +> **Rule**: All channel credentials must come from env vars or `ConfigService` β€” never hardcoded in source. Validate all options with Zod at module startup. + +> **Controller limitation**: Controllers (`enableRestApi`, `enableWebhooks`) cannot be conditionally mounted in `registerAsync` mode and are excluded. Document this clearly when advising consumers. + +--- + +## 🧩 Core Components + +### `NotificationService` (core β€” framework-free) + +The single orchestration point. Inject this in consuming apps. Never inject raw senders or repositories. + +```typescript +// Inject in your NestJS service +constructor(private readonly notifications: NotificationService) {} + +// Send a single notification +const result = await this.notifications.send({ + channel: NotificationChannel.EMAIL, + recipient: { id: 'user-1', email: 'user@example.com' }, + content: { title: 'Welcome', body: 'Hello!', templateId: 'welcome' }, + priority: NotificationPriority.HIGH, +}); + +// Batch send +const results = await this.notifications.sendBatch([...]); +``` + +**Public methods:** + +```typescript +send(dto: SendNotificationDto): Promise +sendBatch(dtos: SendNotificationDto[]): Promise +getById(id: string): Promise +getByRecipient(recipientId: string, filters?): Promise +cancel(id: string): Promise +retry(id: string): Promise +``` + +### `INotificationSender` Port + +All channel senders implement this port. To add a new channel or provider, implement this interface in `infra/senders//`: + +```typescript +// core/ports/notification-sender.port.ts +interface INotificationSender { + readonly channel: NotificationChannel; + send(notification: Notification): Promise; + isConfigured(): boolean; +} +``` + +### `INotificationRepository` Port + +All persistence adapters implement this. Apps never depend on Mongoose schemas directly: + +```typescript +// core/ports/notification-repository.port.ts +interface INotificationRepository { + save(notification: Notification): Promise; + findById(id: string): Promise; + findByRecipient(recipientId: string, filters?): Promise; + updateStatus(id: string, status: NotificationStatus, extra?): Promise; + delete(id: string): Promise; +} +``` + +### `NotificationController` (REST API) + +Mounted when `enableRestApi: true`. Provides: + +| Method | Path | Description | +| ------ | ------------------------------ | ------------------------------ | +| `POST` | `/notifications` | Send a notification | +| `POST` | `/notifications/batch` | Send multiple notifications | +| `GET` | `/notifications/:id` | Get notification by ID | +| `GET` | `/notifications/recipient/:id` | Get notifications by recipient | +| `POST` | `/notifications/:id/cancel` | Cancel a pending notification | +| `POST` | `/notifications/:id/retry` | Retry a failed notification | + +### `WebhookController` + +Mounted when `enableWebhooks: true`. Receives delivery status callbacks from providers (Twilio, Firebase, etc.) and updates notification status accordingly. Must verify provider-specific signatures. + +--- + +## πŸ”Œ Optional Provider Peer Dependencies + +All channel provider SDKs are **optional peer dependencies**. Only install what you use: + +| Channel | Provider | Peer dep | Install when... | +| ------- | ----------- | --------------------- | ---------------------------- | +| Email | Nodemailer | `nodemailer` | Using email channel | +| SMS | Twilio | `twilio` | Using Twilio SMS | +| SMS | Vonage | `@vonage/server-sdk` | Using Vonage SMS | +| SMS | AWS SNS | `@aws-sdk/client-sns` | Using AWS SNS SMS | +| Push | Firebase | `firebase-admin` | Using push notifications | +| Any | Persistence | `mongoose` | Using MongoDB repository | +| Any | Templates | `handlebars` | Using template rendering | +| Any | ID gen | `nanoid` | Using the default ID adapter | + +> **Rule for adding a new provider**: implement `INotificationSender` in `infra/senders//.sender.ts`, guard the import with a clear startup error if the peer dep is missing, and document the peer dep in JSDoc and README. + +--- + +## πŸ§ͺ Testing - RIGOROUS for Modules + +### Coverage Target: 80%+ + +**Unit Tests β€” MANDATORY:** + +- βœ… `core/notification.service.ts` β€” channel routing, retry logic, status lifecycle, error handling +- βœ… All DTOs β€” Zod schema validation, edge cases, invalid inputs +- βœ… All domain errors β€” correct messages, inheritance +- βœ… Each sender adapter β€” success path, failure path, `isConfigured()` guard +- βœ… Each repository adapter β€” CRUD operations, query filters +- βœ… Template provider β€” variable substitution, missing template errors +- βœ… ID generator and datetime providers + +**Integration Tests:** + +- βœ… `NotificationKitModule.register()` β€” correct provider wiring per channel config +- βœ… `NotificationKitModule.registerAsync()` β€” factory injection, full options resolved +- βœ… `NotificationController` β€” full HTTP request/response lifecycle +- βœ… `WebhookController` β€” provider callback β†’ status update flow +- βœ… MongoDB repository β€” real schema operations (with test DB or `mongodb-memory-server`) + +**E2E Tests:** + +- βœ… Send notification β†’ delivery β†’ status update (per channel) +- βœ… Retry flow (failure β†’ retry β†’ success) +- βœ… Scheduled notification lifecycle + +**Test file location:** same directory as source (`*.spec.ts`) + +``` +src/core/ + β”œβ”€β”€ notification.service.ts + └── notification.service.spec.ts + +src/infra/senders/email/ + β”œβ”€β”€ email.sender.ts + └── email.sender.spec.ts +``` + +**Mocking senders and repositories in unit tests:** + +```typescript +const mockSender: INotificationSender = { + channel: NotificationChannel.EMAIL, + send: jest.fn().mockResolvedValue({ success: true, notificationId: "n1" }), + isConfigured: jest.fn().mockReturnValue(true), +}; + +const mockRepository: INotificationRepository = { + save: jest.fn(), + findById: jest.fn(), + findByRecipient: jest.fn(), + updateStatus: jest.fn(), + delete: jest.fn(), +}; +``` + +**Jest Configuration:** + +```javascript +coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, +} +``` + +--- + +## πŸ“š Documentation - Complete + +### JSDoc/TSDoc - ALWAYS for: + +````typescript +/** + * Sends a notification through the specified channel. + * Routes to the appropriate sender adapter, persists the notification, + * and updates its status throughout the delivery lifecycle. + * + * @param dto - Validated send notification payload + * @returns Result containing success status and provider message ID + * + * @throws {ChannelNotConfiguredError} If the channel has no configured provider + * @throws {RecipientMissingFieldError} If the recipient is missing required fields for the channel + * + * @example + * ```typescript + * const result = await notificationService.send({ + * channel: NotificationChannel.EMAIL, + * recipient: { id: 'user-1', email: 'user@example.com' }, + * content: { title: 'Welcome', body: 'Hello!' }, + * priority: NotificationPriority.NORMAL, + * }); + * ``` + */ +async send(dto: SendNotificationDto): Promise +```` + +**Required for:** + +- All public methods on `NotificationService` +- All port interfaces in `core/ports/` +- All exported DTOs (with per-property descriptions) +- All exported domain error classes +- Both `register()` and `registerAsync()` on `NotificationKitModule` +- All sender adapters' `send()` methods (document provider-specific behavior and peer dep) + +### Swagger/OpenAPI β€” ALWAYS on controllers: + +```typescript +@ApiTags('notifications') +@ApiOperation({ summary: 'Send a notification' }) +@ApiBody({ type: SendNotificationDto }) +@ApiResponse({ status: 201, description: 'Notification queued successfully', type: NotificationResultDto }) +@ApiResponse({ status: 400, description: 'Invalid input or missing recipient field' }) +@ApiResponse({ status: 422, description: 'Channel not configured' }) +@Post() +async send(@Body() dto: SendNotificationDto): Promise {} +``` + +--- + +## πŸš€ Module Development Principles + +### 1. Exportability + +**Export ONLY public API:** + +```typescript +// src/index.ts +export * from "./core"; // Types, DTOs, ports, errors, NotificationService +export * from "./infra"; // Senders, repositories, providers +export * from "./nest"; // NotificationKitModule, interfaces +``` + +**❌ NEVER export:** + +- Raw SDK clients (Nodemailer transporter, Twilio client instances) +- Internal `createNotificationKitProviders()` wiring details +- Mongoose schema definitions + +### 2. Configuration + +**All three async patterns supported:** + +```typescript +@Module({}) +export class NotificationKitModule { + static register(options: NotificationKitModuleOptions): DynamicModule { + /* ... */ + } + static registerAsync(options: NotificationKitModuleAsyncOptions): DynamicModule { + // supports useFactory, useClass, useExisting + } +} +``` + +**Controllers are opt-out, not opt-in:** + +```typescript +// Both default to true β€” apps must explicitly disable +NotificationKitModule.register({ enableRestApi: false, enableWebhooks: false }); +``` + +### 3. Zero Business Logic Coupling + +- No hardcoded recipients, templates, credentials, or channel preferences +- All provider credentials from options (never from `process.env` directly inside the module) +- Channel senders are stateless β€” no shared mutable state between requests +- Repository is swappable β€” core service depends only on `INotificationRepository` +- Apps bring their own Mongoose connection β€” this module never creates its own DB connection + +--- + +## πŸ”„ Workflow & Task Management + +### Task-Driven Development + +**1. Branch Creation:** + +```bash +feature/NOTIF-123-add-vonage-sms-sender +bugfix/NOTIF-456-fix-firebase-retry-on-token-expiry +refactor/NOTIF-789-extract-retry-logic-to-core +``` + +**2. Task Documentation:** +Create task file at branch start: + +``` +docs/tasks/active/NOTIF-123-add-vonage-sms-sender.md +``` + +**3. On Release:** +Move to archive: + +``` +docs/tasks/archive/by-release/v1.0.0/NOTIF-123-add-vonage-sms-sender.md +``` + +### Development Workflow + +**Simple changes**: Read context β†’ Implement β†’ Update docs β†’ **Create changeset** + +**Complex changes**: Read context β†’ Discuss approach β†’ Implement β†’ Update docs β†’ **Create changeset** + +**When blocked**: + +- **DO**: Ask immediately +- **DON'T**: Generate incorrect output + +--- + +## πŸ“¦ Versioning & Breaking Changes + +### Semantic Versioning (Strict) + +**MAJOR** (x.0.0) β€” Breaking changes: + +- Changed `NotificationService` public method signatures +- Removed or renamed fields in `SendNotificationDto` or `Notification` +- Changed `NotificationKitModuleOptions` required fields +- Renamed `register()` / `registerAsync()` or changed their call signatures +- Changed `INotificationSender` or `INotificationRepository` port contracts +- Removed a supported channel or provider + +**MINOR** (0.x.0) β€” New features: + +- New channel support (e.g. WhatsApp sender) +- New optional fields in `NotificationKitModuleOptions` +- New provider for an existing channel (e.g. Vonage alongside Twilio) +- New `NotificationService` methods (additive) +- New exported utilities or decorators + +**PATCH** (0.0.x) β€” Bug fixes: + +- Provider-specific delivery fix +- Retry backoff correction +- Template rendering edge case +- Documentation updates + +### Changesets Workflow + +**ALWAYS create a changeset for user-facing changes:** + +```bash +npx changeset +``` + +**When to create a changeset:** + +- βœ… New features, bug fixes, breaking changes, performance improvements +- ❌ Internal refactoring (no user impact) +- ❌ Documentation updates only +- ❌ Test improvements only + +**Before completing any task:** + +- [ ] Code implemented +- [ ] Tests passing +- [ ] Documentation updated +- [ ] **Changeset created** ← CRITICAL +- [ ] PR ready + +**Changeset format:** + +```markdown +--- +"@ciscode/notification-kit": minor +--- + +Added Vonage SMS sender adapter as an alternative to Twilio +``` + +### CHANGELOG Required + +Changesets automatically generates CHANGELOG. For manual additions: + +```markdown +## [1.0.0] - 2026-02-26 + +### BREAKING CHANGES + +- `NotificationService.send()` now requires `priority` field in `SendNotificationDto` +- Removed `createDefaultNotificationService()` β€” use `NotificationKitModule.register()` instead + +### Added + +- Vonage SMS sender adapter +- `sendBatch()` method on `NotificationService` +- In-memory repository for testing and lightweight usage + +### Fixed + +- Firebase push sender now correctly retries on token expiry (401) +``` + +--- + +## πŸ” Security Best Practices + +**ALWAYS:** + +- βœ… Validate all DTOs with Zod at module boundary +- βœ… All provider credentials from env vars β€” never hardcoded +- βœ… Sanitize notification content before logging β€” never log full `templateVars` (may contain PII) +- βœ… Webhook endpoints must verify provider signatures (e.g. `X-Twilio-Signature`) +- βœ… Rate-limit the REST API endpoints in production (document this requirement for consumers) +- βœ… Recipient `metadata` must never appear in error messages or stack traces + +```typescript +// ❌ WRONG β€” logs PII from templateVars +this.logger.error("Template render failed", { notification }); + +// βœ… CORRECT β€” log only safe identifiers +this.logger.error("Template render failed", { + notificationId: notification.id, + channel: notification.channel, +}); +``` + +--- + +## 🚫 Restrictions β€” Require Approval + +**NEVER without approval:** + +- Breaking changes to `NotificationService` public methods +- Removing or renaming fields in `SendNotificationDto`, `Notification`, or `NotificationResult` +- Changing `INotificationSender` or `INotificationRepository` port contracts +- Removing a supported channel or provider adapter +- Renaming `register()` / `registerAsync()` or their option shapes +- Security-related changes (webhook signature verification, credential handling) + +**CAN do autonomously:** + +- Bug fixes (non-breaking) +- New optional `NotificationKitModuleOptions` fields +- New sender adapter for an existing channel (e.g. AWS SES alongside Nodemailer) +- Internal refactoring within a single layer (no public API or port contract change) +- Test and documentation improvements + +--- + +## βœ… Release Checklist + +Before publishing: + +- [ ] All tests passing (100% of test suite) +- [ ] Coverage >= 80% +- [ ] No ESLint warnings (`--max-warnings=0`) +- [ ] TypeScript strict mode passing (`tsc --noEmit`) +- [ ] `npm run build` succeeds β€” both `.mjs` and `.cjs` outputs in `dist/` +- [ ] All public APIs documented (JSDoc) +- [ ] All new `NotificationKitModuleOptions` fields documented in README +- [ ] Optional peer deps documented (which to install for which channel) +- [ ] Changeset created +- [ ] Breaking changes highlighted in changeset +- [ ] Integration tested via `npm link` in a real consuming NestJS app + +--- + +## πŸ”„ Development Workflow + +### Working on the Module: + +1. Clone the repo +2. Create branch: `feature/NOTIF-123-description` from `develop` +3. Implement with tests +4. **Create changeset**: `npx changeset` +5. Verify checklist +6. Create PR β†’ `develop` + +### Testing in a Consuming App: + +```bash +# In notification-kit +npm run build +npm link + +# In your NestJS app +cd ~/ciscode/backend +npm link @ciscode/notification-kit + +# Develop and test +# Unlink when done +npm unlink @ciscode/notification-kit +``` + +--- + +## 🎨 Code Style + +- ESLint `--max-warnings=0` +- Prettier formatting +- TypeScript strict mode +- Pure functions in `core/` (no side effects, no SDK calls) +- OOP classes for NestJS providers and sender/repository adapters +- Dependency injection via constructor β€” never property-based `@Inject()` +- Sender adapters are stateless β€” no mutable instance variables after construction + +```typescript +// βœ… Correct β€” constructor injection, stateless sender +@Injectable() +export class EmailSender implements INotificationSender { + readonly channel = NotificationChannel.EMAIL; + + constructor( + @Inject(NOTIFICATION_KIT_OPTIONS) + private readonly options: NotificationKitModuleOptions, + ) {} + + async send(notification: Notification): Promise { + /* ... */ + } + isConfigured(): boolean { + return !!this.options.channels?.email; + } +} + +// ❌ Wrong β€” property injection, mutable state +@Injectable() +export class EmailSender { + @Inject(NOTIFICATION_KIT_OPTIONS) private options: NotificationKitModuleOptions; + private transporter: any; // mutated after construction ← FORBIDDEN +} +``` + +--- + +## πŸ› Error Handling + +**Custom domain errors β€” ALWAYS in `core/errors/`:** + +```typescript +export class ChannelNotConfiguredError extends Error { + constructor(channel: NotificationChannel) { + super( + `Channel "${channel}" is not configured. Did you pass options for it in NotificationKitModule.register()?`, + ); + this.name = "ChannelNotConfiguredError"; + } +} + +export class NotificationNotFoundError extends Error { + constructor(id: string) { + super(`Notification "${id}" not found`); + this.name = "NotificationNotFoundError"; + } +} +``` + +**Structured logging β€” safe identifiers only:** + +```typescript +this.logger.error("Notification delivery failed", { + notificationId: notification.id, + channel: notification.channel, + provider: "twilio", + attempt: notification.retryCount, +}); +``` + +**NEVER silent failures:** + +```typescript +// ❌ WRONG +try { + await sender.send(notification); +} catch { + // silent +} + +// βœ… CORRECT +try { + await sender.send(notification); +} catch (error) { + await this.repository.updateStatus(notification.id, NotificationStatus.FAILED, { + error: (error as Error).message, + }); + throw error; +} +``` + +--- + +## πŸ’¬ Communication Style + +- Brief and direct +- Reference the correct layer (`core`, `infra`, `nest`) when discussing changes +- Always name the channel and provider when discussing sender-related changes +- Flag breaking changes immediately β€” even suspected ones +- This module is consumed by multiple services β€” when in doubt about impact, ask + +--- + +## πŸ“‹ Summary + +**Module Principles:** + +1. Reusability over specificity +2. Comprehensive testing (80%+) +3. Complete documentation +4. Strict versioning +5. Breaking changes = MAJOR bump + changeset +6. Zero app coupling β€” no hardcoded credentials, recipients, or templates +7. Configurable behavior via `NotificationKitModuleOptions` + +**Layer ownership β€” quick reference:** + +| Concern | Owner | +| ---------------------------- | ---------------------------------- | +| Domain types & enums | `src/core/types.ts` | +| DTOs & Zod validation | `src/core/dtos/` | +| Port interfaces | `src/core/ports/` | +| Orchestration logic | `src/core/notification.service.ts` | +| Domain errors | `src/core/errors/` | +| Channel sender adapters | `src/infra/senders//` | +| Persistence adapters | `src/infra/repositories/` | +| Utility adapters | `src/infra/providers/` | +| NestJS DI, module, providers | `src/nest/` | +| REST API & webhook endpoints | `src/nest/controllers/` | +| All public exports | `src/index.ts` | + +**When in doubt:** Ask, don't assume. This module delivers notifications across production services. + +--- + +_Last Updated: February 26, 2026_ +_Version: 1.0.0_ diff --git a/.github/instructions/features.instructions.md b/.github/instructions/features.instructions.md new file mode 100644 index 0000000..f8a193c --- /dev/null +++ b/.github/instructions/features.instructions.md @@ -0,0 +1,354 @@ +# Features Instructions - Kit Module + +> **Last Updated**: February 2026 + +--- + +## πŸš€ Before Starting Any Feature + +### Pre-Implementation Checklist + +- [ ] **Check existing functionality** - Avoid duplication +- [ ] **Understand scope** - Breaking change? (MAJOR version) +- [ ] **Review public API impact** - Changes to exports? +- [ ] **Check dependencies** - Need new npm packages? +- [ ] **Plan backwards compatibility** - Can users upgrade smoothly? +- [ ] **Consider security** - Impact on auth/authorization? + +### Questions to Ask + +1. **Already implemented?** + + ```bash + grep -r "featureName" src/ + ``` + +2. **Right place for this?** + - Should this be in host app? + - Too specific to one use case? + +3. **Impact assessment?** + - Breaking β†’ MAJOR version + - New feature β†’ MINOR version + - Enhancement β†’ PATCH version + +--- + +## πŸ“‹ Implementation Workflow + +``` +1. Design β†’ 2. Implement β†’ 3. Test β†’ 4. Document β†’ 5. Release +``` + +### 1️⃣ Design Phase + +- [ ] Plan interface/method signatures +- [ ] Define error handling strategy +- [ ] Identify affected files +- [ ] Consider migration (if breaking) + +### 2️⃣ Implementation Phase + +- [ ] Create feature branch: `feature/description` +- [ ] Implement services layer +- [ ] Add repository methods (if needed) +- [ ] Update controllers (if needed) +- [ ] Add guards/middleware (if needed) +- [ ] Handle errors +- [ ] Add logging + +### 3️⃣ Testing Phase + +- [ ] Unit tests for services +- [ ] Integration tests for controllers +- [ ] Error scenario tests +- [ ] Edge case tests +- [ ] Coverage >= 80% + +### 4️⃣ Documentation Phase + +- [ ] Update JSDoc for public methods +- [ ] Update README with examples +- [ ] Update CHANGELOG +- [ ] Add troubleshooting notes + +### 5️⃣ Release Phase + +- [ ] Bump version: `npm version [minor|major]` +- [ ] Test in host app +- [ ] Create PR to `develop` +- [ ] Release from `master` + +--- + +## βž• Adding New Service Methods + +### Example: Add `listByStatus()` Method + +**Step 1: Design Interface** + +````typescript +/** + * Retrieve items filtered by status + * @param status - The status to filter by + * @returns Array of items with matching status + * @throws {BadRequestException} If status is invalid + * @example + * ```typescript + * const active = await service.listByStatus('active'); + * ``` + */ +async listByStatus(status: string): Promise +```` + +**Step 2: Add Repository Method** + +```typescript +// src/repositories/item.repository.ts +@Injectable() +export class ItemRepository { + async findByStatus(status: string) { + return this.itemModel.find({ status }).lean(); + } +} +``` + +**Step 3: Implement Service Method** + +```typescript +// src/services/item.service.ts +@Injectable() +export class ItemService { + constructor( + private readonly items: ItemRepository, + private readonly logger: LoggerService, + ) {} + + async listByStatus(status: string) { + // Validate input + const validStatuses = ["active", "inactive", "pending"]; + if (!validStatuses.includes(status)) { + throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(", ")}`); + } + + try { + const items = await this.items.findByStatus(status); + this.logger.log(`Retrieved ${items.length} items with status ${status}`, "ItemService"); + return items; + } catch (error) { + this.logger.error(`Failed to list by status: ${error.message}`, error.stack, "ItemService"); + throw new InternalServerErrorException("Failed to retrieve items"); + } + } +} +``` + +**Step 4: Add Controller Endpoint (Optional)** + +```typescript +// src/controllers/item.controller.ts +@Controller("api/items") +@UseGuards(AuthenticateGuard) +export class ItemController { + @Get("status/:status") + async getByStatus(@Param("status") status: string) { + return this.itemService.listByStatus(status); + } +} +``` + +**Step 5: Write Tests** + +```typescript +// src/services/item.service.spec.ts +describe("listByStatus", () => { + it("should return items with matching status", async () => { + const mockItems = [{ id: "1", status: "active" }]; + mockRepository.findByStatus.mockResolvedValue(mockItems); + + const result = await service.listByStatus("active"); + + expect(result).toEqual(mockItems); + expect(mockRepository.findByStatus).toHaveBeenCalledWith("active"); + }); + + it("should throw BadRequestException for invalid status", async () => { + await expect(service.listByStatus("invalid")).rejects.toThrow(BadRequestException); + }); + + it("should throw InternalServerErrorException on DB error", async () => { + mockRepository.findByStatus.mockRejectedValue(new Error("DB error")); + + await expect(service.listByStatus("active")).rejects.toThrow(InternalServerErrorException); + }); +}); +``` + +--- + +## πŸ”§ Adding New DTOs + +### Example: CreateItemDto + +```typescript +// src/dtos/create-item.dto.ts +import { IsNotEmpty, IsString, IsEnum, IsOptional } from "class-validator"; + +export class CreateItemDto { + @IsNotEmpty({ message: "Name is required" }) + @IsString({ message: "Name must be a string" }) + name: string; + + @IsEnum(["active", "inactive"], { + message: "Status must be active or inactive", + }) + status: "active" | "inactive"; + + @IsOptional() + @IsString() + description?: string; +} +``` + +--- + +## πŸ›‘οΈ Adding New Guards + +### Example: RoleGuard + +```typescript +// src/middleware/role.guard.ts +import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; + +@Injectable() +export class RoleGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRole = this.reflector.get("role", context.getHandler()); + + if (!requiredRole) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + return user?.role === requiredRole; + } +} + +// Decorator +export const RequireRole = (role: string) => SetMetadata("role", role); +``` + +**Usage:** + +```typescript +@Controller("api/admin") +@UseGuards(AuthenticateGuard, RoleGuard) +export class AdminController { + @Get() + @RequireRole("admin") + getAdminData() { + return "Admin only data"; + } +} +``` + +--- + +## πŸ“š Exporting New Functionality + +**Update module exports:** + +```typescript +// src/index.ts +export { ItemService } from "./services/item.service"; +export { CreateItemDto } from "./dtos/create-item.dto"; +export { RoleGuard, RequireRole } from "./middleware/role.guard"; +``` + +--- + +## ⚠️ Breaking Changes + +### How to Handle + +**Version 1.x.x β†’ 2.0.0:** + +1. **Document the change** in CHANGELOG +2. **Provide migration guide** +3. **Consider deprecation period** + +**Example migration guide:** + +````markdown +## Breaking Changes in v2.0.0 + +### Changed Method Signature + +**Before (v1.x):** + +```typescript +await service.createItem(name, status); +``` +```` + +**After (v2.0):** + +```typescript +await service.createItem({ name, status }); +``` + +### Migration Steps + +1. Update all calls to use object parameter +2. Run tests to verify + +```` + +--- + +## πŸ“¦ Adding Dependencies + +**When adding new npm package:** + +```bash +npm install package-name +```` + +**Update package.json:** + +```json +{ + "dependencies": { + "package-name": "^1.0.0" + } +} +``` + +** Document in README:** + +```markdown +## Dependencies + +- `package-name` - Brief description of why needed +``` + +--- + +## πŸ“‹ Feature Completion Checklist + +- [ ] Interface designed +- [ ] Code implemented +- [ ] Tests written (80%+ coverage) +- [ ] JSDoc added +- [ ] README updated +- [ ] CHANGELOG updated +- [ ] Exports updated in index.ts +- [ ] Breaking changes documented +- [ ] Migration guide (if breaking) +- [ ] Tested in host app +- [ ] PR created diff --git a/.github/instructions/general.instructions.md b/.github/instructions/general.instructions.md new file mode 100644 index 0000000..e031c25 --- /dev/null +++ b/.github/instructions/general.instructions.md @@ -0,0 +1,239 @@ +# General Instructions - Kit Module + +> **Last Updated**: February 2026 + +--- + +## πŸ“¦ Package Overview + +### What is this module? + +This is a production-ready NestJS module providing enterprise-grade functionality for modern applications. + +**Type**: Backend NestJS Module +**Framework**: NestJS 10+/11+ with MongoDB + Mongoose +**Distribution**: NPM package +**License**: MIT + +### Key Characteristics + +| Characteristic | Description | +| ----------------- | ----------------------------------------------------------- | +| **Architecture** | Repository pattern, dependency injection, layered structure | +| **Database** | MongoDB via Mongoose (host app connection) | +| **Security** | Secure by default, follows NestJS best practices | +| **Extensibility** | Configurable via env vars, exportable services/decorators | +| **Testing** | Target: 80%+ coverage | + +--- + +## πŸ—οΈ Architecture Pattern + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ CONTROLLERS LAYER β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ HTTP Request Handlers β”‚ β”‚ +β”‚ β”‚ - Validation β”‚ β”‚ +β”‚ β”‚ - Routing β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SERVICES LAYER β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Business Logic β”‚ β”‚ +β”‚ β”‚ - Core Operations β”‚ β”‚ +β”‚ β”‚ - Validation β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ REPOSITORIES LAYER β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Database Abstraction β”‚ β”‚ +β”‚ β”‚ - CRUD Operations β”‚ β”‚ +β”‚ β”‚ - Queries β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ MODELS LAYER β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Mongoose Schemas β”‚ β”‚ +β”‚ β”‚ - Data Models β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## πŸ“ File Structure + +``` +src/ +β”œβ”€β”€ controllers/ # HTTP request handlers +β”œβ”€β”€ services/ # Business logic +β”œβ”€β”€ repositories/ # Database abstraction +β”œβ”€β”€ models/ # Mongoose schemas +β”œβ”€β”€ dtos/ # Data Transfer Objects +β”œβ”€β”€ middleware/ # Guards, interceptors +β”œβ”€β”€ utils/ # Helper functions +└── index.ts # Public API exports +``` + +--- + +## πŸ“ Coding Standards + +### TypeScript Strictness + +```typescript +// Always use strict types +interface UserData { + id: string; + name: string; +} + +// βœ… Good +function getUser(id: string): Promise; + +// ❌ Bad +function getUser(id: any): Promise; +``` + +### Error Handling + +```typescript +// βœ… Use NestJS exceptions +throw new NotFoundException("Resource not found"); + +// ❌ Don't use generic errors +throw new Error("Not found"); +``` + +### Async/Await + +```typescript +// βœ… Always use async/await +async function fetchData() { + const result = await repository.find(); + return result; +} + +// ❌ Avoid promise chains +function fetchData() { + return repository.find().then((result) => result); +} +``` + +--- + +## πŸ” Security Best Practices + +- Validate all inputs using DTOs +- Use guards for authorization +- Never expose sensitive data +- Log security events +- Use environment variables for secrets + +--- + +## πŸ“š Documentation Requirements + +### JSDoc for Public Methods + +```typescript +/** + * Retrieve item by ID + * @param id - The item identifier + * @returns The item or null if not found + * @throws {NotFoundException} If item doesn't exist + */ +async findById(id: string): Promise +``` + +--- + +## πŸ§ͺ Testing Philosophy + +- **Target**: 80%+ code coverage +- **Test behavior**, not implementation +- **Mock external dependencies** +- **Test edge cases and error scenarios** + +--- + +## πŸš€ Development Workflow + +1. **Design** - Plan interface and data flow +2. **Implement** - Write code following standards +3. **Test** - Unit and integration tests +4. **Document** - JSDoc and README updates +5. **Release** - Semantic versioning + +--- + +## ⚠️ Common Gotchas + +### 1. Module Imports + +```typescript +// βœ… Use path aliases +import { UserService } from "@services/user.service"; + +// ❌ Relative imports +import { UserService } from "../../../services/user.service"; +``` + +### 2. Dependency Injection + +```typescript +// βœ… Inject dependencies +constructor( + private readonly userService: UserService, + private readonly logger: LoggerService, +) {} + +// ❌ Create instances +const userService = new UserService(); +``` + +--- + +## πŸ“¦ Environment Configuration + +Required environment variables should be documented: + +```bash +# Database +MONGO_URI=mongodb://localhost:27017/database + +# Application +NODE_ENV=development +PORT=3000 +``` + +--- + +## πŸ” Debugging Tips + +- Use NestJS built-in logger +- Add debug logs at key points +- Use VS Code debugger +- Check MongoDB queries + +--- + +## πŸ“‹ Pre-Release Checklist + +- [ ] All tests passing +- [ ] Coverage >= 80% +- [ ] JSDoc complete +- [ ] README updated +- [ ] CHANGELOG updated +- [ ] No console.log statements +- [ ] Environment vars documented diff --git a/.github/instructions/sonarqube_mcp.instructions.md b/.github/instructions/sonarqube_mcp.instructions.md new file mode 100644 index 0000000..61523c0 --- /dev/null +++ b/.github/instructions/sonarqube_mcp.instructions.md @@ -0,0 +1,50 @@ +--- +applyTo: "**/*" +--- + +These are some guidelines when using the SonarQube MCP server. + +# Important Tool Guidelines + +## Basic usage + +- **IMPORTANT**: After you finish generating or modifying any code files at the very end of the task, you MUST call the `analyze_file_list` tool (if it exists) to analyze the files you created or modified. +- **IMPORTANT**: When starting a new task, you MUST disable automatic analysis with the `toggle_automatic_analysis` tool if it exists. +- **IMPORTANT**: When you are done generating code at the very end of the task, you MUST re-enable automatic analysis with the `toggle_automatic_analysis` tool if it exists. + +## Project Keys + +- When a user mentions a project key, use `search_my_sonarqube_projects` first to find the exact project key +- Don't guess project keys - always look them up + +## Code Language Detection + +- When analyzing code snippets, try to detect the programming language from the code syntax +- If unclear, ask the user or make an educated guess based on syntax + +## Branch and Pull Request Context + +- Many operations support branch-specific analysis +- If user mentions working on a feature branch, include the branch parameter + +## Code Issues and Violations + +- After fixing issues, do not attempt to verify them using `search_sonar_issues_in_projects`, as the server will not yet reflect the updates + +# Common Troubleshooting + +## Authentication Issues + +- SonarQube requires USER tokens (not project tokens) +- When the error `SonarQube answered with Not authorized` occurs, verify the token type + +## Project Not Found + +- Use `search_my_sonarqube_projects` to find available projects +- Verify project key spelling and format + +## Code Analysis Issues + +- Ensure programming language is correctly specified +- Remind users that snippet analysis doesn't replace full project scans +- Provide full file content for better analysis results diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md new file mode 100644 index 0000000..956e4ab --- /dev/null +++ b/.github/instructions/testing.instructions.md @@ -0,0 +1,349 @@ +# Testing Instructions - Kit Module + +> **Last Updated**: February 2026 +> **Testing Framework**: Jest +> **Coverage Target**: 80%+ + +--- + +## 🎯 Testing Philosophy + +### Test Behavior, Not Implementation + +**βœ… Test what the code does:** + +```typescript +it("should throw error when user not found", async () => { + await expect(service.findById("invalid-id")).rejects.toThrow(NotFoundException); +}); +``` + +**❌ Don't test how it does it:** + +```typescript +it("should call repository.findById", async () => { + const spy = jest.spyOn(repository, "findById"); + await service.findById("id"); + expect(spy).toHaveBeenCalled(); // Testing implementation! +}); +``` + +--- + +## πŸ“Š Coverage Targets + +| Layer | Minimum Coverage | Priority | +| ---------------- | ---------------- | ----------- | +| **Services** | 90%+ | πŸ”΄ Critical | +| **Repositories** | 70%+ | 🟑 High | +| **Guards** | 95%+ | πŸ”΄ Critical | +| **Controllers** | 80%+ | 🟒 Medium | +| **DTOs** | 100% | πŸ”΄ Critical | +| **Utils** | 80%+ | 🟒 Medium | + +**Overall Target**: 80%+ + +--- + +## πŸ“ Test File Organization + +### File Placement + +Tests live next to the code: + +``` +src/services/ + β”œβ”€β”€ user.service.ts + └── user.service.spec.ts ← Same directory +``` + +### Naming Convention + +| Code File | Test File | +| -------------------- | ------------------------- | +| `user.service.ts` | `user.service.spec.ts` | +| `user.repository.ts` | `user.repository.spec.ts` | + +--- + +## 🎭 Test Structure + +### Standard Template + +```typescript +import { Test, TestingModule } from "@nestjs/testing"; +import { ServiceUnderTest } from "./service-under-test"; +import { DependencyOne } from "./dependency-one"; + +describe("ServiceUnderTest", () => { + let service: ServiceUnderTest; + let dependency: jest.Mocked; + + beforeEach(async () => { + const mockDependency = { + method: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ServiceUnderTest, { provide: DependencyOne, useValue: mockDependency }], + }).compile(); + + service = module.get(ServiceUnderTest); + dependency = module.get(DependencyOne); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("methodName", () => { + it("should return expected result", async () => { + // Arrange + dependency.method.mockResolvedValue("data"); + + // Act + const result = await service.methodName(); + + // Assert + expect(result).toBe("expected"); + }); + + it("should handle errors", async () => { + // Arrange + dependency.method.mockRejectedValue(new Error("DB error")); + + // Act & Assert + await expect(service.methodName()).rejects.toThrow(InternalServerErrorException); + }); + }); +}); +``` + +--- + +## 🎭 Mocking Patterns + +### Mocking Repositories + +```typescript +const mockRepository = { + findById: jest.fn(), + create: jest.fn(), + updateById: jest.fn(), + deleteById: jest.fn(), + list: jest.fn(), +}; + +// In test +mockRepository.findById.mockResolvedValue({ + _id: "id", + name: "Test", +}); +``` + +### Mocking Mongoose Models + +```typescript +const mockModel = { + findById: jest.fn().mockReturnThis(), + findOne: jest.fn().mockReturnThis(), + find: jest.fn().mockReturnThis(), + populate: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue({}), + exec: jest.fn(), +}; +``` + +### Mocking NestJS Logger + +```typescript +const mockLogger = { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), +}; +``` + +--- + +## πŸ“‹ Test Categories + +### 1. Service Tests + +**What to test:** + +- βœ… Business logic correctness +- βœ… Error handling +- βœ… Edge cases +- βœ… State changes + +**Example:** + +```typescript +describe("createItem", () => { + it("should create item with valid data", async () => { + mockRepository.create.mockResolvedValue(mockItem); + + const result = await service.createItem(validDto); + + expect(result).toEqual(mockItem); + }); + + it("should throw BadRequestException for invalid data", async () => { + await expect(service.createItem(invalidDto)).rejects.toThrow(BadRequestException); + }); +}); +``` + +### 2. Repository Tests + +**What to test:** + +- βœ… CRUD operations +- βœ… Query logic +- βœ… Population/aggregation + +**Example:** + +```typescript +describe("findByEmail", () => { + it("should return user when email exists", async () => { + modelMock.findOne.mockResolvedValue(mockUser); + + const user = await repository.findByEmail("test@example.com"); + + expect(user).toEqual(mockUser); + expect(modelMock.findOne).toHaveBeenCalledWith({ + email: "test@example.com", + }); + }); +}); +``` + +### 3. Guard Tests + +**What to test:** + +- βœ… Allow authorized requests +- βœ… Deny unauthorized requests +- βœ… Token validation +- βœ… Role checks + +**Example:** + +```typescript +describe("canActivate", () => { + it("should allow authenticated users", async () => { + const context = createMockContext(validToken); + + const result = await guard.canActivate(context); + + expect(result).toBe(true); + }); + + it("should deny missing token", async () => { + const context = createMockContext(null); + + await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); + }); +}); +``` + +### 4. Controller Tests + +**What to test:** + +- βœ… Route handlers call correct service methods +- βœ… Response formatting +- βœ… Error propagation + +**Example:** + +```typescript +describe("getItems", () => { + it("should return list of items", async () => { + mockService.list.mockResolvedValue([mockItem]); + + const result = await controller.getItems(); + + expect(result).toEqual([mockItem]); + expect(mockService.list).toHaveBeenCalled(); + }); +}); +``` + +--- + +## πŸ§ͺ Test Commands + +```bash +# Run all tests +npm test + +# Watch mode +npm run test:watch + +# Coverage report +npm run test:coverage + +# Run specific test file +npm test -- user.service.spec.ts +``` + +--- + +## ⚠️ Common Mistakes + +### 1. Testing Implementation Details + +```typescript +// ❌ BAD +it("should call bcrypt.hash", () => { + const spy = jest.spyOn(bcrypt, "hash"); + service.method(); + expect(spy).toHaveBeenCalled(); +}); + +// βœ… GOOD +it("should hash password", async () => { + const result = await service.hashPassword("password"); + expect(result).not.toBe("password"); + expect(result.length).toBeGreaterThan(20); +}); +``` + +### 2. Not Cleaning Up Mocks + +```typescript +// βœ… Always clean up +afterEach(() => { + jest.clearAllMocks(); +}); +``` + +### 3. Ignoring Async + +```typescript +// ❌ Missing await +it("test", () => { + expect(service.asyncMethod()).resolves.toBe("value"); +}); + +// βœ… Proper async handling +it("test", async () => { + await expect(service.asyncMethod()).resolves.toBe("value"); +}); +``` + +--- + +## πŸ“‹ Pre-Merge Checklist + +- [ ] All tests passing +- [ ] Coverage >= 80% +- [ ] No skipped tests (it.skip) +- [ ] No focused tests (it.only) +- [ ] Mocks cleaned up in afterEach +- [ ] Async operations properly awaited +- [ ] Error cases tested From 18815b9888c4fdb9a0f3428330bc6cf29b638b7c Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Tue, 3 Mar 2026 21:48:39 +0000 Subject: [PATCH 10/13] refactor: move instruction files to .github/instructions/ - Remove deprecated instruction files from .github/ root - Consolidate all docs in .github/instructions/ directory - Improve documentation organization --- .github/copilot-instructions.md | 903 -------------------------- .github/sonarqube_mcp.instructions.md | 50 -- .github/workflows/publish.yml | 40 +- 3 files changed, 28 insertions(+), 965 deletions(-) delete mode 100644 .github/copilot-instructions.md delete mode 100644 .github/sonarqube_mcp.instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 14512f5..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,903 +0,0 @@ -# Copilot Instructions - @ciscode/notification-kit - -> **Purpose**: Universal NestJS notification library supporting multi-channel delivery (Email, SMS, Push, In-App, Webhook) with pluggable provider backends, template support, persistence, and a built-in REST + Webhook API. - ---- - -## 🎯 Package Overview - -**Package**: `@ciscode/notification-kit` -**Type**: Backend NestJS Notification Module -**Purpose**: Centralized, multi-channel notification delivery with pluggable providers, retry logic, status tracking, and scheduling β€” usable across all `@ciscode/*` services - -### This Package Provides: - -- CSR (Controller-Service-Repository) architecture with Clean Architecture ports -- `NotificationKitModule` β€” global NestJS dynamic module (`register` / `registerAsync`) -- `NotificationService` β€” injectable orchestration service (core, framework-free) -- `NotificationController` β€” REST API for sending and querying notifications -- `WebhookController` β€” inbound webhook receiver for provider delivery callbacks -- Channel senders: **Email** (Nodemailer), **SMS** (Twilio / Vonage / AWS SNS), **Push** (Firebase), **In-App**, **Webhook** -- Repository adapters: **MongoDB** (Mongoose) and **In-Memory** -- Template rendering via Handlebars -- Zod-validated configuration -- Changesets for version management -- Husky + lint-staged for code quality -- Copilot-friendly development guidelines - ---- - -## πŸ—οΈ Module Architecture - -**NotificationKit uses CSR (Controller-Service-Repository) + Ports & Adapters for maximum reusability and provider interchangeability.** - -> **WHY CSR + Ports?** Reusable notification libraries must support multiple providers without coupling business logic to any specific SDK. Ports (interfaces) in `core/` define the contracts; adapters in `infra/` implement them. Apps choose which adapters to wire. - -``` -src/ - β”œβ”€β”€ index.ts # PUBLIC API β€” all exports go through here - β”‚ - β”œβ”€β”€ core/ # βœ… Framework-FREE (no NestJS imports) - β”‚ β”œβ”€β”€ index.ts - β”‚ β”œβ”€β”€ types.ts # Domain entities & enums - β”‚ β”œβ”€β”€ dtos/ # Input/output contracts (Zod-validated) - β”‚ β”œβ”€β”€ ports/ # Abstractions (interfaces the infra implements) - β”‚ β”‚ β”œβ”€β”€ notification-sender.port.ts # INotificationSender - β”‚ β”‚ β”œβ”€β”€ notification-repository.port.ts # INotificationRepository - β”‚ β”‚ └── (template, event, id, datetime ports) - β”‚ β”œβ”€β”€ errors/ # Domain errors - β”‚ └── notification.service.ts # Core orchestration logic (framework-free) - β”‚ - β”œβ”€β”€ infra/ # Concrete adapter implementations - β”‚ β”œβ”€β”€ index.ts - β”‚ β”œβ”€β”€ senders/ # Channel sender adapters - β”‚ β”‚ β”œβ”€β”€ email/ # Nodemailer adapter - β”‚ β”‚ β”œβ”€β”€ sms/ # Twilio / Vonage / AWS SNS adapters - β”‚ β”‚ β”œβ”€β”€ push/ # Firebase adapter - β”‚ β”‚ β”œβ”€β”€ in-app/ # In-app adapter - β”‚ β”‚ └── webhook/ # Outbound webhook adapter - β”‚ β”œβ”€β”€ repositories/ # Persistence adapters - β”‚ β”‚ β”œβ”€β”€ mongodb/ # Mongoose adapter - β”‚ β”‚ └── in-memory/ # In-memory adapter (testing / simple usage) - β”‚ └── providers/ # Utility adapters - β”‚ β”œβ”€β”€ id-generator/ # nanoid adapter - β”‚ β”œβ”€β”€ datetime/ # Date/time utilities - β”‚ β”œβ”€β”€ template/ # Handlebars adapter - β”‚ └── events/ # Event bus adapter - β”‚ - └── nest/ # NestJS integration layer - β”œβ”€β”€ index.ts - β”œβ”€β”€ module.ts # NotificationKitModule - β”œβ”€β”€ interfaces.ts # NotificationKitModuleOptions, AsyncOptions, Factory - β”œβ”€β”€ constants.ts # NOTIFICATION_KIT_OPTIONS token - β”œβ”€β”€ providers.ts # createNotificationKitProviders() factory - └── controllers/ - β”œβ”€β”€ notification.controller.ts # REST API (enable via enableRestApi) - └── webhook.controller.ts # Inbound webhooks (enable via enableWebhooks) -``` - -**Responsibility Layers:** - -| Layer | Responsibility | Examples | -| ----------------- | ---------------------------------------------------------- | ----------------------------------------------------------- | -| **Controllers** | HTTP handling, REST API, inbound webhook receivers | `NotificationController`, `WebhookController` | -| **Core Service** | Orchestration, channel routing, retry, status lifecycle | `notification.service.ts` | -| **DTOs** | Input validation, API contracts (Zod) | `SendNotificationDto`, `NotificationQueryDto` | -| **Ports** | Abstractions β€” what `core/` depends on | `INotificationSender`, `INotificationRepository` | -| **Senders** | Channel delivery β€” implement `INotificationSender` | `EmailSender`, `SmsSender`, `PushSender` | -| **Repositories** | Persistence β€” implement `INotificationRepository` | `MongoNotificationRepository`, `InMemoryRepository` | -| **Providers** | Cross-cutting utilities | `HandlebarsTemplateProvider`, `NanoidGenerator` | -| **Domain Types** | Entities, enums, value objects (immutable, framework-free) | `Notification`, `NotificationChannel`, `NotificationStatus` | -| **Domain Errors** | Typed, named error classes | `ChannelNotConfiguredError`, `NotificationNotFoundError` | - -### Layer Import Rules β€” STRICTLY ENFORCED - -| Layer | Can import from | Cannot import from | -| ------- | ---------------------- | ------------------ | -| `core` | Nothing internal | `infra`, `nest` | -| `infra` | `core` (ports & types) | `nest` | -| `nest` | `core`, `infra` | β€” | - -> **The golden rule**: `core/` must compile with zero NestJS or provider SDK imports. If you're adding a NestJS decorator or importing `nodemailer` inside `core/`, it's in the wrong layer. - ---- - -## πŸ“ Naming Conventions - -### Files - -**Pattern**: `kebab-case` + suffix - -| Type | Example | Directory | -| ---------------- | ---------------------------------- | --------------------------------- | -| Module | `module.ts` | `src/nest/` | -| Controller | `notification.controller.ts` | `src/nest/controllers/` | -| Core Service | `notification.service.ts` | `src/core/` | -| Port interface | `notification-sender.port.ts` | `src/core/ports/` | -| DTO | `send-notification.dto.ts` | `src/core/dtos/` | -| Domain Error | `notification-not-found.error.ts` | `src/core/errors/` | -| Sender adapter | `email.sender.ts` | `src/infra/senders/email/` | -| Repository | `mongo-notification.repository.ts` | `src/infra/repositories/mongodb/` | -| Utility provider | `handlebars-template.provider.ts` | `src/infra/providers/template/` | -| Constants | `constants.ts` | `src/nest/` | - -### Code Naming - -- **Classes & Interfaces**: `PascalCase` β†’ `NotificationService`, `INotificationSender`, `SendNotificationDto` -- **Variables & functions**: `camelCase` β†’ `sendNotification`, `buildProviders` -- **Constants / DI tokens**: `UPPER_SNAKE_CASE` β†’ `NOTIFICATION_KIT_OPTIONS`, `NOTIFICATION_SENDER`, `NOTIFICATION_REPOSITORY` -- **Enums**: Name `PascalCase`, values match protocol strings - -```typescript -// βœ… Correct enum definitions -enum NotificationChannel { - EMAIL = "email", - SMS = "sms", - PUSH = "push", - IN_APP = "in_app", - WEBHOOK = "webhook", -} - -enum NotificationStatus { - PENDING = "pending", - QUEUED = "queued", - SENDING = "sending", - SENT = "sent", - DELIVERED = "delivered", - FAILED = "failed", - CANCELLED = "cancelled", -} -``` - -### Path Aliases (`tsconfig.json`) - -```typescript -"@/*" β†’ "src/*" -"@core/*" β†’ "src/core/*" -"@infra/*" β†’ "src/infra/*" -"@nest/*" β†’ "src/nest/*" -``` - -Use aliases for cleaner imports: - -```typescript -import { NotificationService } from "@core/notification.service"; -import { INotificationSender } from "@core/ports/notification-sender.port"; -import { SendNotificationDto } from "@core/dtos/send-notification.dto"; -import { EmailSender } from "@infra/senders/email/email.sender"; -``` - ---- - -## πŸ“¦ Public API β€” `src/index.ts` - -```typescript -// βœ… All exports go through here β€” never import from deep paths in consuming apps -export * from "./core"; // Types, DTOs, ports, errors, NotificationService -export * from "./infra"; // Senders, repositories, utility providers -export * from "./nest"; // NotificationKitModule, interfaces, constants -``` - -**What consuming apps should use:** - -```typescript -import { - NotificationKitModule, - NotificationService, - SendNotificationDto, - NotificationChannel, - NotificationStatus, - NotificationPriority, - type Notification, - type NotificationResult, - type INotificationSender, // for custom adapter implementations - type INotificationRepository, // for custom adapter implementations -} from "@ciscode/notification-kit"; -``` - -**❌ NEVER export:** - -- Internal provider wiring (`createNotificationKitProviders` internals) -- Raw SDK instances (Nodemailer transporter, Twilio client, Firebase app) -- Mongoose schema definitions (infrastructure details) - ---- - -## βš™οΈ Module Registration - -### `register()` β€” sync - -```typescript -NotificationKitModule.register({ - channels: { - email: { - provider: "nodemailer", - from: "no-reply@ciscode.com", - smtp: { host: "smtp.example.com", port: 587, auth: { user: "...", pass: "..." } }, - }, - sms: { - provider: "twilio", - accountSid: process.env.TWILIO_SID, - authToken: process.env.TWILIO_TOKEN, - from: process.env.TWILIO_FROM, - }, - push: { - provider: "firebase", - serviceAccount: JSON.parse(process.env.FIREBASE_SA!), - }, - }, - repository: { type: "mongodb", uri: process.env.MONGO_URI }, - templates: { engine: "handlebars", dir: "./templates" }, - enableRestApi: true, // default: true - enableWebhooks: true, // default: true - retries: { max: 3, backoff: "exponential" }, -}); -``` - -### `registerAsync()` β€” with ConfigService - -```typescript -NotificationKitModule.registerAsync({ - imports: [ConfigModule], - inject: [ConfigService], - useFactory: (config: ConfigService) => ({ - channels: { - email: { provider: "nodemailer", from: config.get("EMAIL_FROM") /* ... */ }, - sms: { provider: config.get("SMS_PROVIDER") /* ... */ }, - }, - repository: { type: config.get("DB_TYPE"), uri: config.get("MONGO_URI") }, - enableRestApi: config.get("NOTIF_REST_API", true), - enableWebhooks: config.get("NOTIF_WEBHOOKS", true), - }), -}); -``` - -### `registerAsync()` β€” with `useClass` / `useExisting` - -```typescript -// useClass β€” module instantiates the factory -NotificationKitModule.registerAsync({ useClass: NotificationKitConfigService }); - -// useExisting β€” reuse an already-provided factory -NotificationKitModule.registerAsync({ useExisting: NotificationKitConfigService }); -``` - -> **Rule**: All channel credentials must come from env vars or `ConfigService` β€” never hardcoded in source. Validate all options with Zod at module startup. - -> **Controller limitation**: Controllers (`enableRestApi`, `enableWebhooks`) cannot be conditionally mounted in `registerAsync` mode and are excluded. Document this clearly when advising consumers. - ---- - -## 🧩 Core Components - -### `NotificationService` (core β€” framework-free) - -The single orchestration point. Inject this in consuming apps. Never inject raw senders or repositories. - -```typescript -// Inject in your NestJS service -constructor(private readonly notifications: NotificationService) {} - -// Send a single notification -const result = await this.notifications.send({ - channel: NotificationChannel.EMAIL, - recipient: { id: 'user-1', email: 'user@example.com' }, - content: { title: 'Welcome', body: 'Hello!', templateId: 'welcome' }, - priority: NotificationPriority.HIGH, -}); - -// Batch send -const results = await this.notifications.sendBatch([...]); -``` - -**Public methods:** - -```typescript -send(dto: SendNotificationDto): Promise -sendBatch(dtos: SendNotificationDto[]): Promise -getById(id: string): Promise -getByRecipient(recipientId: string, filters?): Promise -cancel(id: string): Promise -retry(id: string): Promise -``` - -### `INotificationSender` Port - -All channel senders implement this port. To add a new channel or provider, implement this interface in `infra/senders//`: - -```typescript -// core/ports/notification-sender.port.ts -interface INotificationSender { - readonly channel: NotificationChannel; - send(notification: Notification): Promise; - isConfigured(): boolean; -} -``` - -### `INotificationRepository` Port - -All persistence adapters implement this. Apps never depend on Mongoose schemas directly: - -```typescript -// core/ports/notification-repository.port.ts -interface INotificationRepository { - save(notification: Notification): Promise; - findById(id: string): Promise; - findByRecipient(recipientId: string, filters?): Promise; - updateStatus(id: string, status: NotificationStatus, extra?): Promise; - delete(id: string): Promise; -} -``` - -### `NotificationController` (REST API) - -Mounted when `enableRestApi: true`. Provides: - -| Method | Path | Description | -| ------ | ------------------------------ | ------------------------------ | -| `POST` | `/notifications` | Send a notification | -| `POST` | `/notifications/batch` | Send multiple notifications | -| `GET` | `/notifications/:id` | Get notification by ID | -| `GET` | `/notifications/recipient/:id` | Get notifications by recipient | -| `POST` | `/notifications/:id/cancel` | Cancel a pending notification | -| `POST` | `/notifications/:id/retry` | Retry a failed notification | - -### `WebhookController` - -Mounted when `enableWebhooks: true`. Receives delivery status callbacks from providers (Twilio, Firebase, etc.) and updates notification status accordingly. Must verify provider-specific signatures. - ---- - -## πŸ”Œ Optional Provider Peer Dependencies - -All channel provider SDKs are **optional peer dependencies**. Only install what you use: - -| Channel | Provider | Peer dep | Install when... | -| ------- | ----------- | --------------------- | ---------------------------- | -| Email | Nodemailer | `nodemailer` | Using email channel | -| SMS | Twilio | `twilio` | Using Twilio SMS | -| SMS | Vonage | `@vonage/server-sdk` | Using Vonage SMS | -| SMS | AWS SNS | `@aws-sdk/client-sns` | Using AWS SNS SMS | -| Push | Firebase | `firebase-admin` | Using push notifications | -| Any | Persistence | `mongoose` | Using MongoDB repository | -| Any | Templates | `handlebars` | Using template rendering | -| Any | ID gen | `nanoid` | Using the default ID adapter | - -> **Rule for adding a new provider**: implement `INotificationSender` in `infra/senders//.sender.ts`, guard the import with a clear startup error if the peer dep is missing, and document the peer dep in JSDoc and README. - ---- - -## πŸ§ͺ Testing - RIGOROUS for Modules - -### Coverage Target: 80%+ - -**Unit Tests β€” MANDATORY:** - -- βœ… `core/notification.service.ts` β€” channel routing, retry logic, status lifecycle, error handling -- βœ… All DTOs β€” Zod schema validation, edge cases, invalid inputs -- βœ… All domain errors β€” correct messages, inheritance -- βœ… Each sender adapter β€” success path, failure path, `isConfigured()` guard -- βœ… Each repository adapter β€” CRUD operations, query filters -- βœ… Template provider β€” variable substitution, missing template errors -- βœ… ID generator and datetime providers - -**Integration Tests:** - -- βœ… `NotificationKitModule.register()` β€” correct provider wiring per channel config -- βœ… `NotificationKitModule.registerAsync()` β€” factory injection, full options resolved -- βœ… `NotificationController` β€” full HTTP request/response lifecycle -- βœ… `WebhookController` β€” provider callback β†’ status update flow -- βœ… MongoDB repository β€” real schema operations (with test DB or `mongodb-memory-server`) - -**E2E Tests:** - -- βœ… Send notification β†’ delivery β†’ status update (per channel) -- βœ… Retry flow (failure β†’ retry β†’ success) -- βœ… Scheduled notification lifecycle - -**Test file location:** same directory as source (`*.spec.ts`) - -``` -src/core/ - β”œβ”€β”€ notification.service.ts - └── notification.service.spec.ts - -src/infra/senders/email/ - β”œβ”€β”€ email.sender.ts - └── email.sender.spec.ts -``` - -**Mocking senders and repositories in unit tests:** - -```typescript -const mockSender: INotificationSender = { - channel: NotificationChannel.EMAIL, - send: jest.fn().mockResolvedValue({ success: true, notificationId: "n1" }), - isConfigured: jest.fn().mockReturnValue(true), -}; - -const mockRepository: INotificationRepository = { - save: jest.fn(), - findById: jest.fn(), - findByRecipient: jest.fn(), - updateStatus: jest.fn(), - delete: jest.fn(), -}; -``` - -**Jest Configuration:** - -```javascript -coverageThreshold: { - global: { - branches: 80, - functions: 80, - lines: 80, - statements: 80, - }, -} -``` - ---- - -## πŸ“š Documentation - Complete - -### JSDoc/TSDoc - ALWAYS for: - -````typescript -/** - * Sends a notification through the specified channel. - * Routes to the appropriate sender adapter, persists the notification, - * and updates its status throughout the delivery lifecycle. - * - * @param dto - Validated send notification payload - * @returns Result containing success status and provider message ID - * - * @throws {ChannelNotConfiguredError} If the channel has no configured provider - * @throws {RecipientMissingFieldError} If the recipient is missing required fields for the channel - * - * @example - * ```typescript - * const result = await notificationService.send({ - * channel: NotificationChannel.EMAIL, - * recipient: { id: 'user-1', email: 'user@example.com' }, - * content: { title: 'Welcome', body: 'Hello!' }, - * priority: NotificationPriority.NORMAL, - * }); - * ``` - */ -async send(dto: SendNotificationDto): Promise -```` - -**Required for:** - -- All public methods on `NotificationService` -- All port interfaces in `core/ports/` -- All exported DTOs (with per-property descriptions) -- All exported domain error classes -- Both `register()` and `registerAsync()` on `NotificationKitModule` -- All sender adapters' `send()` methods (document provider-specific behavior and peer dep) - -### Swagger/OpenAPI β€” ALWAYS on controllers: - -```typescript -@ApiTags('notifications') -@ApiOperation({ summary: 'Send a notification' }) -@ApiBody({ type: SendNotificationDto }) -@ApiResponse({ status: 201, description: 'Notification queued successfully', type: NotificationResultDto }) -@ApiResponse({ status: 400, description: 'Invalid input or missing recipient field' }) -@ApiResponse({ status: 422, description: 'Channel not configured' }) -@Post() -async send(@Body() dto: SendNotificationDto): Promise {} -``` - ---- - -## πŸš€ Module Development Principles - -### 1. Exportability - -**Export ONLY public API:** - -```typescript -// src/index.ts -export * from "./core"; // Types, DTOs, ports, errors, NotificationService -export * from "./infra"; // Senders, repositories, providers -export * from "./nest"; // NotificationKitModule, interfaces -``` - -**❌ NEVER export:** - -- Raw SDK clients (Nodemailer transporter, Twilio client instances) -- Internal `createNotificationKitProviders()` wiring details -- Mongoose schema definitions - -### 2. Configuration - -**All three async patterns supported:** - -```typescript -@Module({}) -export class NotificationKitModule { - static register(options: NotificationKitModuleOptions): DynamicModule { - /* ... */ - } - static registerAsync(options: NotificationKitModuleAsyncOptions): DynamicModule { - // supports useFactory, useClass, useExisting - } -} -``` - -**Controllers are opt-out, not opt-in:** - -```typescript -// Both default to true β€” apps must explicitly disable -NotificationKitModule.register({ enableRestApi: false, enableWebhooks: false }); -``` - -### 3. Zero Business Logic Coupling - -- No hardcoded recipients, templates, credentials, or channel preferences -- All provider credentials from options (never from `process.env` directly inside the module) -- Channel senders are stateless β€” no shared mutable state between requests -- Repository is swappable β€” core service depends only on `INotificationRepository` -- Apps bring their own Mongoose connection β€” this module never creates its own DB connection - ---- - -## πŸ”„ Workflow & Task Management - -### Task-Driven Development - -**1. Branch Creation:** - -```bash -feature/NOTIF-123-add-vonage-sms-sender -bugfix/NOTIF-456-fix-firebase-retry-on-token-expiry -refactor/NOTIF-789-extract-retry-logic-to-core -``` - -**2. Task Documentation:** -Create task file at branch start: - -``` -docs/tasks/active/NOTIF-123-add-vonage-sms-sender.md -``` - -**3. On Release:** -Move to archive: - -``` -docs/tasks/archive/by-release/v1.0.0/NOTIF-123-add-vonage-sms-sender.md -``` - -### Development Workflow - -**Simple changes**: Read context β†’ Implement β†’ Update docs β†’ **Create changeset** - -**Complex changes**: Read context β†’ Discuss approach β†’ Implement β†’ Update docs β†’ **Create changeset** - -**When blocked**: - -- **DO**: Ask immediately -- **DON'T**: Generate incorrect output - ---- - -## πŸ“¦ Versioning & Breaking Changes - -### Semantic Versioning (Strict) - -**MAJOR** (x.0.0) β€” Breaking changes: - -- Changed `NotificationService` public method signatures -- Removed or renamed fields in `SendNotificationDto` or `Notification` -- Changed `NotificationKitModuleOptions` required fields -- Renamed `register()` / `registerAsync()` or changed their call signatures -- Changed `INotificationSender` or `INotificationRepository` port contracts -- Removed a supported channel or provider - -**MINOR** (0.x.0) β€” New features: - -- New channel support (e.g. WhatsApp sender) -- New optional fields in `NotificationKitModuleOptions` -- New provider for an existing channel (e.g. Vonage alongside Twilio) -- New `NotificationService` methods (additive) -- New exported utilities or decorators - -**PATCH** (0.0.x) β€” Bug fixes: - -- Provider-specific delivery fix -- Retry backoff correction -- Template rendering edge case -- Documentation updates - -### Changesets Workflow - -**ALWAYS create a changeset for user-facing changes:** - -```bash -npx changeset -``` - -**When to create a changeset:** - -- βœ… New features, bug fixes, breaking changes, performance improvements -- ❌ Internal refactoring (no user impact) -- ❌ Documentation updates only -- ❌ Test improvements only - -**Before completing any task:** - -- [ ] Code implemented -- [ ] Tests passing -- [ ] Documentation updated -- [ ] **Changeset created** ← CRITICAL -- [ ] PR ready - -**Changeset format:** - -```markdown ---- -"@ciscode/notification-kit": minor ---- - -Added Vonage SMS sender adapter as an alternative to Twilio -``` - -### CHANGELOG Required - -Changesets automatically generates CHANGELOG. For manual additions: - -```markdown -## [1.0.0] - 2026-02-26 - -### BREAKING CHANGES - -- `NotificationService.send()` now requires `priority` field in `SendNotificationDto` -- Removed `createDefaultNotificationService()` β€” use `NotificationKitModule.register()` instead - -### Added - -- Vonage SMS sender adapter -- `sendBatch()` method on `NotificationService` -- In-memory repository for testing and lightweight usage - -### Fixed - -- Firebase push sender now correctly retries on token expiry (401) -``` - ---- - -## πŸ” Security Best Practices - -**ALWAYS:** - -- βœ… Validate all DTOs with Zod at module boundary -- βœ… All provider credentials from env vars β€” never hardcoded -- βœ… Sanitize notification content before logging β€” never log full `templateVars` (may contain PII) -- βœ… Webhook endpoints must verify provider signatures (e.g. `X-Twilio-Signature`) -- βœ… Rate-limit the REST API endpoints in production (document this requirement for consumers) -- βœ… Recipient `metadata` must never appear in error messages or stack traces - -```typescript -// ❌ WRONG β€” logs PII from templateVars -this.logger.error("Template render failed", { notification }); - -// βœ… CORRECT β€” log only safe identifiers -this.logger.error("Template render failed", { - notificationId: notification.id, - channel: notification.channel, -}); -``` - ---- - -## 🚫 Restrictions β€” Require Approval - -**NEVER without approval:** - -- Breaking changes to `NotificationService` public methods -- Removing or renaming fields in `SendNotificationDto`, `Notification`, or `NotificationResult` -- Changing `INotificationSender` or `INotificationRepository` port contracts -- Removing a supported channel or provider adapter -- Renaming `register()` / `registerAsync()` or their option shapes -- Security-related changes (webhook signature verification, credential handling) - -**CAN do autonomously:** - -- Bug fixes (non-breaking) -- New optional `NotificationKitModuleOptions` fields -- New sender adapter for an existing channel (e.g. AWS SES alongside Nodemailer) -- Internal refactoring within a single layer (no public API or port contract change) -- Test and documentation improvements - ---- - -## βœ… Release Checklist - -Before publishing: - -- [ ] All tests passing (100% of test suite) -- [ ] Coverage >= 80% -- [ ] No ESLint warnings (`--max-warnings=0`) -- [ ] TypeScript strict mode passing (`tsc --noEmit`) -- [ ] `npm run build` succeeds β€” both `.mjs` and `.cjs` outputs in `dist/` -- [ ] All public APIs documented (JSDoc) -- [ ] All new `NotificationKitModuleOptions` fields documented in README -- [ ] Optional peer deps documented (which to install for which channel) -- [ ] Changeset created -- [ ] Breaking changes highlighted in changeset -- [ ] Integration tested via `npm link` in a real consuming NestJS app - ---- - -## πŸ”„ Development Workflow - -### Working on the Module: - -1. Clone the repo -2. Create branch: `feature/NOTIF-123-description` from `develop` -3. Implement with tests -4. **Create changeset**: `npx changeset` -5. Verify checklist -6. Create PR β†’ `develop` - -### Testing in a Consuming App: - -```bash -# In notification-kit -npm run build -npm link - -# In your NestJS app -cd ~/ciscode/backend -npm link @ciscode/notification-kit - -# Develop and test -# Unlink when done -npm unlink @ciscode/notification-kit -``` - ---- - -## 🎨 Code Style - -- ESLint `--max-warnings=0` -- Prettier formatting -- TypeScript strict mode -- Pure functions in `core/` (no side effects, no SDK calls) -- OOP classes for NestJS providers and sender/repository adapters -- Dependency injection via constructor β€” never property-based `@Inject()` -- Sender adapters are stateless β€” no mutable instance variables after construction - -```typescript -// βœ… Correct β€” constructor injection, stateless sender -@Injectable() -export class EmailSender implements INotificationSender { - readonly channel = NotificationChannel.EMAIL; - - constructor( - @Inject(NOTIFICATION_KIT_OPTIONS) - private readonly options: NotificationKitModuleOptions, - ) {} - - async send(notification: Notification): Promise { - /* ... */ - } - isConfigured(): boolean { - return !!this.options.channels?.email; - } -} - -// ❌ Wrong β€” property injection, mutable state -@Injectable() -export class EmailSender { - @Inject(NOTIFICATION_KIT_OPTIONS) private options: NotificationKitModuleOptions; - private transporter: any; // mutated after construction ← FORBIDDEN -} -``` - ---- - -## πŸ› Error Handling - -**Custom domain errors β€” ALWAYS in `core/errors/`:** - -```typescript -export class ChannelNotConfiguredError extends Error { - constructor(channel: NotificationChannel) { - super( - `Channel "${channel}" is not configured. Did you pass options for it in NotificationKitModule.register()?`, - ); - this.name = "ChannelNotConfiguredError"; - } -} - -export class NotificationNotFoundError extends Error { - constructor(id: string) { - super(`Notification "${id}" not found`); - this.name = "NotificationNotFoundError"; - } -} -``` - -**Structured logging β€” safe identifiers only:** - -```typescript -this.logger.error("Notification delivery failed", { - notificationId: notification.id, - channel: notification.channel, - provider: "twilio", - attempt: notification.retryCount, -}); -``` - -**NEVER silent failures:** - -```typescript -// ❌ WRONG -try { - await sender.send(notification); -} catch { - // silent -} - -// βœ… CORRECT -try { - await sender.send(notification); -} catch (error) { - await this.repository.updateStatus(notification.id, NotificationStatus.FAILED, { - error: (error as Error).message, - }); - throw error; -} -``` - ---- - -## πŸ’¬ Communication Style - -- Brief and direct -- Reference the correct layer (`core`, `infra`, `nest`) when discussing changes -- Always name the channel and provider when discussing sender-related changes -- Flag breaking changes immediately β€” even suspected ones -- This module is consumed by multiple services β€” when in doubt about impact, ask - ---- - -## πŸ“‹ Summary - -**Module Principles:** - -1. Reusability over specificity -2. Comprehensive testing (80%+) -3. Complete documentation -4. Strict versioning -5. Breaking changes = MAJOR bump + changeset -6. Zero app coupling β€” no hardcoded credentials, recipients, or templates -7. Configurable behavior via `NotificationKitModuleOptions` - -**Layer ownership β€” quick reference:** - -| Concern | Owner | -| ---------------------------- | ---------------------------------- | -| Domain types & enums | `src/core/types.ts` | -| DTOs & Zod validation | `src/core/dtos/` | -| Port interfaces | `src/core/ports/` | -| Orchestration logic | `src/core/notification.service.ts` | -| Domain errors | `src/core/errors/` | -| Channel sender adapters | `src/infra/senders//` | -| Persistence adapters | `src/infra/repositories/` | -| Utility adapters | `src/infra/providers/` | -| NestJS DI, module, providers | `src/nest/` | -| REST API & webhook endpoints | `src/nest/controllers/` | -| All public exports | `src/index.ts` | - -**When in doubt:** Ask, don't assume. This module delivers notifications across production services. - ---- - -_Last Updated: February 26, 2026_ -_Version: 1.0.0_ diff --git a/.github/sonarqube_mcp.instructions.md b/.github/sonarqube_mcp.instructions.md deleted file mode 100644 index 61523c0..0000000 --- a/.github/sonarqube_mcp.instructions.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -applyTo: "**/*" ---- - -These are some guidelines when using the SonarQube MCP server. - -# Important Tool Guidelines - -## Basic usage - -- **IMPORTANT**: After you finish generating or modifying any code files at the very end of the task, you MUST call the `analyze_file_list` tool (if it exists) to analyze the files you created or modified. -- **IMPORTANT**: When starting a new task, you MUST disable automatic analysis with the `toggle_automatic_analysis` tool if it exists. -- **IMPORTANT**: When you are done generating code at the very end of the task, you MUST re-enable automatic analysis with the `toggle_automatic_analysis` tool if it exists. - -## Project Keys - -- When a user mentions a project key, use `search_my_sonarqube_projects` first to find the exact project key -- Don't guess project keys - always look them up - -## Code Language Detection - -- When analyzing code snippets, try to detect the programming language from the code syntax -- If unclear, ask the user or make an educated guess based on syntax - -## Branch and Pull Request Context - -- Many operations support branch-specific analysis -- If user mentions working on a feature branch, include the branch parameter - -## Code Issues and Violations - -- After fixing issues, do not attempt to verify them using `search_sonar_issues_in_projects`, as the server will not yet reflect the updates - -# Common Troubleshooting - -## Authentication Issues - -- SonarQube requires USER tokens (not project tokens) -- When the error `SonarQube answered with Not authorized` occurs, verify the token type - -## Project Not Found - -- Use `search_my_sonarqube_projects` to find available projects -- Verify project key spelling and format - -## Code Analysis Issues - -- Ensure programming language is correctly specified -- Remind users that snippet analysis doesn't replace full project scans -- Provide full file content for better analysis results diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 57fb5bb..91d232e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,43 +2,59 @@ name: Publish to NPM on: push: - tags: - - "v*.*.*" + branches: + - master workflow_dispatch: jobs: publish: runs-on: ubuntu-latest - permissions: contents: read packages: write + id-token: write steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate tag exists on this push + run: | + TAG=$(git describe --exact-match --tags HEAD 2>/dev/null || echo "") + if [[ -z "$TAG" ]]; then + echo "❌ No tag found on HEAD. This push did not include a version tag." + echo "To publish, merge to master with a tag: git tag v1.0.0 && git push origin master --tags" + exit 1 + fi + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "❌ Invalid tag format: $TAG. Expected: v*.*.*" + exit 1 + fi + echo "βœ… Valid tag found: $TAG" + echo "TAG_VERSION=$TAG" >> $GITHUB_ENV - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "20" registry-url: "https://registry.npmjs.org" + cache: "npm" - name: Install dependencies run: npm ci - - name: Run lint (if present) - run: npm run lint --if-present - continue-on-error: false + - name: Build + run: npm run build --if-present - - name: Run tests (if present) - run: npm test --if-present - continue-on-error: false + - name: Lint + run: npm run lint --if-present 2>/dev/null || true - - name: Build package - run: npm run build + - name: Test + run: npm test --if-present 2>/dev/null || true - name: Publish to NPM - run: npm publish --access public + run: npm publish --access public --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From 95c840b0928e31108c77ed7565843ccc77975587 Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Wed, 4 Mar 2026 00:52:06 +0000 Subject: [PATCH 11/13] fix: add mongoose and ts-node to devDependencies - Mongoose required for type compilation (infra/repositories/mongoose) - ts-node required by Jest configuration - Resolves typecheck and test errors --- package-lock.json | 1207 +++------------------------------------------ package.json | 4 +- 2 files changed, 65 insertions(+), 1146 deletions(-) diff --git a/package-lock.json b/package-lock.json index 734bbbb..50be98e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "devDependencies": { "@changesets/cli": "^2.27.7", "@types/jest": "^29.5.14", + "@types/mongoose": "^5.11.96", "@types/node": "^22.10.7", "eslint": "^9.18.0", "eslint-plugin-import": "^2.32.0", @@ -21,9 +22,10 @@ "husky": "^9.1.7", "jest": "^29.7.0", "lint-staged": "^16.2.7", - "mongoose": "^9.2.3", + "mongoose": "^9.2.4", "prettier": "^3.4.2", "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", "tsup": "^8.3.5", "typescript": "^5.7.3", "typescript-eslint": "^8.50.1" @@ -1128,8 +1130,6 @@ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -1143,8 +1143,6 @@ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -2566,29 +2564,6 @@ } } }, - "node_modules/@nestjs/platform-express": { - "version": "11.1.10", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.10.tgz", - "integrity": "sha512-B2kvhfY+pE41Y6MXuJs80T7yfYjXzqHkWVyZJ5CAa3nFN3X2OIca6RH+b+7l3wZ+4x1tgsv48Q2P8ZfrDqJWYQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "cors": "2.8.5", - "express": "5.2.1", - "multer": "2.0.2", - "path-to-regexp": "8.3.0", - "tslib": "2.8.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nest" - }, - "peerDependencies": { - "@nestjs/common": "^11.0.0", - "@nestjs/core": "^11.0.0" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2986,248 +2961,6 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@swc/core": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.7.tgz", - "integrity": "sha512-kTGB8XI7P+pTKW83tnUEDVP4zduF951u3UAOn5eTi0vyW6MvL56A3+ggMdfuVFtDI0/DsbSzf5z34HVBbuScWw==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.25" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.7", - "@swc/core-darwin-x64": "1.15.7", - "@swc/core-linux-arm-gnueabihf": "1.15.7", - "@swc/core-linux-arm64-gnu": "1.15.7", - "@swc/core-linux-arm64-musl": "1.15.7", - "@swc/core-linux-x64-gnu": "1.15.7", - "@swc/core-linux-x64-musl": "1.15.7", - "@swc/core-win32-arm64-msvc": "1.15.7", - "@swc/core-win32-ia32-msvc": "1.15.7", - "@swc/core-win32-x64-msvc": "1.15.7" - }, - "peerDependencies": { - "@swc/helpers": ">=0.5.17" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.7.tgz", - "integrity": "sha512-+hNVUfezUid7LeSHqnhoC6Gh3BROABxjlDNInuZ/fie1RUxaEX4qzDwdTgozJELgHhvYxyPIg1ro8ibnKtgO4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.7.tgz", - "integrity": "sha512-ZAFuvtSYZTuXPcrhanaD5eyp27H8LlDzx2NAeVyH0FchYcuXf0h5/k3GL9ZU6Jw9eQ63R1E8KBgpXEJlgRwZUQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.7.tgz", - "integrity": "sha512-K3HTYocpqnOw8KcD8SBFxiDHjIma7G/X+bLdfWqf+qzETNBrzOub/IEkq9UaeupaJiZJkPptr/2EhEXXWryS/A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.7.tgz", - "integrity": "sha512-HCnVIlsLnCtQ3uXcXgWrvQ6SAraskLA9QJo9ykTnqTH6TvUYqEta+TdTdGjzngD6TOE7XjlAiUs/RBtU8Z0t+Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.7.tgz", - "integrity": "sha512-/OOp9UZBg4v2q9+x/U21Jtld0Wb8ghzBScwhscI7YvoSh4E8RALaJ1msV8V8AKkBkZH7FUAFB7Vbv0oVzZsezA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.7.tgz", - "integrity": "sha512-VBbs4gtD4XQxrHuQ2/2+TDZpPQQgrOHYRnS6SyJW+dw0Nj/OomRqH+n5Z4e/TgKRRbieufipeIGvADYC/90PYQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.7.tgz", - "integrity": "sha512-kVuy2unodso6p0rMauS2zby8/bhzoGRYxBDyD6i2tls/fEYAE74oP0VPFzxIyHaIjK1SN6u5TgvV9MpyJ5xVug==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.7.tgz", - "integrity": "sha512-uddYoo5Xmo1XKLhAnh4NBIyy5d0xk33x1sX3nIJboFySLNz878ksCFCZ3IBqrt1Za0gaoIWoOSSSk0eNhAc/sw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.7.tgz", - "integrity": "sha512-rqq8JjNMLx3QNlh0aPTtN/4+BGLEHC94rj9mkH1stoNRf3ra6IksNHMHy+V1HUqElEgcZyx+0yeXx3eLOTcoFw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.7.tgz", - "integrity": "sha512-4BK06EGdPnuplgcNhmSbOIiLdRgHYX3v1nl4HXo5uo4GZMfllXaCyBUes+0ePRfwbn9OFgVhCWPcYYjMT6hycQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true - }, - "node_modules/@swc/types": { - "version": "0.1.25", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", - "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@swc/counter": "^0.1.3" - } - }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -3258,36 +2991,28 @@ "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3403,6 +3128,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mongoose": { + "version": "5.11.96", + "resolved": "https://registry.npmjs.org/@types/mongoose/-/mongoose-5.11.96.tgz", + "integrity": "sha512-keiY22ljJtXyM7osgScmZOHV6eL5VFUD5tQumlu+hjS++HND5nM8jNEdj5CSWfKIJpVwQfPuwQ2SfBqUnCAVRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mongoose": "*" + } + }, "node_modules/@types/node": { "version": "22.19.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", @@ -3710,21 +3445,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -3749,13 +3469,11 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "acorn": "^8.11.0" }, @@ -3866,22 +3584,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/append-field": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", - "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/argparse": { "version": "2.0.1", @@ -4204,32 +3912,6 @@ "node": ">=4" } }, - "node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -4325,7 +4007,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/bundle-require": { @@ -4344,38 +4026,14 @@ "esbuild": ">=0.18" } }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "optional": true, - "peer": true, - "dependencies": { - "streamsearch": "^1.1.0" - }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "node": ">=8" } }, "node_modules/call-bind": { @@ -4401,7 +4059,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4415,7 +4073,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4716,23 +4374,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "engines": [ - "node >= 6.0" - ], - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - } - }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -4749,17 +4390,6 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4767,43 +4397,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -4831,9 +4424,7 @@ "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -4989,17 +4580,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/detect-indent": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", @@ -5021,13 +4601,11 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, "license": "BSD-3-Clause", - "optional": true, - "peer": true, "engines": { "node": ">=0.3.1" } @@ -5072,7 +4650,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -5083,14 +4661,6 @@ "node": ">= 0.4" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -5118,17 +4688,6 @@ "dev": true, "license": "MIT" }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -5239,7 +4798,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5249,7 +4808,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5259,7 +4818,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -5367,14 +4926,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -5696,17 +5247,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", @@ -5771,66 +5311,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/extendable-error": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.7.tgz", @@ -5979,29 +5459,6 @@ "node": ">=8" } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -6068,28 +5525,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -6116,7 +5551,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "devOptional": true, + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6200,7 +5635,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -6235,7 +5670,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -6344,7 +5779,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6448,7 +5883,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6477,7 +5912,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6493,28 +5928,6 @@ "dev": true, "license": "MIT" }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/human-id": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz", @@ -6555,7 +5968,7 @@ "version": "0.7.1", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -6662,7 +6075,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/internal-slot": { @@ -6680,17 +6093,6 @@ "node": ">= 0.4" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6965,14 +6367,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -8320,23 +7714,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", @@ -8344,20 +7727,6 @@ "dev": true, "license": "MIT" }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -8402,35 +7771,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -8471,26 +7811,12 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "devOptional": true, + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -8566,9 +7892,9 @@ } }, "node_modules/mongoose": { - "version": "9.2.3", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.2.3.tgz", - "integrity": "sha512-4XFKKkXUOsdY+p07eJyio4mk0rzZOT4n5r5tLqZNeRZ/IsS68vS8Szw8uShX4p7S687XGGc+MFAp+6K1OIN0aw==", + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.2.4.tgz", + "integrity": "sha512-XNh+jiztVMddDFDCv8TWxVxi/rGx+0FfsK3Ftj6hcYzEmhTcos2uC144OJRmUFPHSu3hJr6Pgip++Ab2+Da35Q==", "dev": true, "license": "MIT", "dependencies": { @@ -8623,77 +7949,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "append-field": "^1.0.0", - "busboy": "^1.6.0", - "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" - }, - "engines": { - "node": ">= 10.16.0" - } - }, - "node_modules/multer/node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/multer/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/multer/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/multer/node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -8726,17 +7981,6 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -8785,7 +8029,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8795,7 +8039,7 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8888,25 +8132,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -9078,17 +8308,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -9161,21 +8380,6 @@ "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/pidtree": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", @@ -9411,21 +8615,6 @@ "node": ">= 6" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9453,23 +8642,6 @@ ], "license": "MIT" }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -9508,34 +8680,6 @@ ], "license": "MIT" }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -9593,22 +8737,6 @@ "node": ">=4" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -9841,24 +8969,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -9913,28 +9023,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -9974,7 +9062,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/semver": { @@ -9990,55 +9078,6 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -10088,14 +9127,6 @@ "node": ">= 0.4" } }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -10123,7 +9154,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -10143,7 +9174,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -10160,7 +9191,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -10179,7 +9210,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -10329,17 +9360,6 @@ "node": ">=8" } }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -10354,27 +9374,6 @@ "node": ">= 0.4" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -10715,17 +9714,6 @@ "node": ">=8.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.6" - } - }, "node_modules/token-types": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz", @@ -10860,8 +9848,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11016,22 +10002,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -11110,14 +10080,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -11229,17 +10191,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -11281,22 +10232,12 @@ "punycode": "^2.1.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/v8-to-istanbul": { "version": "9.3.0", @@ -11313,17 +10254,6 @@ "node": ">=10.12.0" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -11569,7 +10499,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -11593,17 +10523,6 @@ "dev": true, "license": "ISC" }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.4" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -11672,8 +10591,6 @@ "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=6" } diff --git a/package.json b/package.json index d516f7a..7e6faa6 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "devDependencies": { "@changesets/cli": "^2.27.7", "@types/jest": "^29.5.14", + "@types/mongoose": "^5.11.96", "@types/node": "^22.10.7", "eslint": "^9.18.0", "eslint-plugin-import": "^2.32.0", @@ -89,9 +90,10 @@ "husky": "^9.1.7", "jest": "^29.7.0", "lint-staged": "^16.2.7", - "mongoose": "^9.2.3", + "mongoose": "^9.2.4", "prettier": "^3.4.2", "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", "tsup": "^8.3.5", "typescript": "^5.7.3", "typescript-eslint": "^8.50.1" From 12dfa56e861b8b0d53d009f290e063991c9c641a Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Thu, 12 Mar 2026 09:17:41 +0000 Subject: [PATCH 12/13] 0.0.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index bad9d1d..c451962 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ciscode/notification-kit", - "version": "0.0.0", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ciscode/notification-kit", - "version": "0.0.0", + "version": "0.0.1", "license": "MIT", "dependencies": { "zod": "^3.24.1" diff --git a/package.json b/package.json index 8399c44..94d0a88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ciscode/notification-kit", - "version": "0.0.0", + "version": "0.0.1", "description": "A notification library for NestJS applications.", "author": "CisCode", "publishConfig": { From 7ed77ce4297893ad9d1905f4f9990664cc2dbd35 Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Thu, 12 Mar 2026 09:31:57 +0000 Subject: [PATCH 13/13] ops: updated publish workflow --- .github/workflows/publish.yml | 42 ++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 91d232e..dfb361a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,19 +20,51 @@ jobs: with: fetch-depth: 0 - - name: Validate tag exists on this push + - name: Validate version tag and package.json run: | - TAG=$(git describe --exact-match --tags HEAD 2>/dev/null || echo "") + # Try to find a version tag on HEAD or recent commits + TAG=$(git describe --exact-match --tags HEAD 2>/dev/null || git tag --list --sort=-version:refname --merged HEAD 'v*.*.*' | head -1 || echo "") + if [[ -z "$TAG" ]]; then - echo "❌ No tag found on HEAD. This push did not include a version tag." - echo "To publish, merge to master with a tag: git tag v1.0.0 && git push origin master --tags" + echo "❌ ERROR: No version tag found!" + echo "" + echo "This typically happens when:" + echo " 1. You forgot to run 'npm version patch|minor|major' on develop" + echo " 2. You didn't push tags when merging developβ†’master" + echo " 3. PR merge didn't include the tag from develop" + echo "" + echo "πŸ“‹ Correct workflow:" + echo " 1. On develop: npm version patch (or minor/major)" + echo " 2. On develop: git push origin develop --tags" + echo " 3. Create PR developβ†’master and merge" + echo " 4. Workflow automatically triggers on master with the tag" + echo "" exit 1 fi + + # Validate tag format if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "❌ Invalid tag format: $TAG. Expected: v*.*.*" + echo "❌ ERROR: Invalid tag format: '$TAG'" + echo "Expected format: v*.*.* (e.g., v1.0.0, v0.2.3)" exit 1 fi + + # Extract version from tag + TAG_VERSION="${TAG#v}" # Remove 'v' prefix + PKG_VERSION=$(grep '"version"' package.json | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/') + + # Verify package.json version matches tag + if [[ "$TAG_VERSION" != "$PKG_VERSION" ]]; then + echo "❌ ERROR: Version mismatch!" + echo " Tag version: $TAG_VERSION" + echo " package.json: $PKG_VERSION" + echo "" + echo "Fix: Make sure you ran 'npm version' before pushing" + exit 1 + fi + echo "βœ… Valid tag found: $TAG" + echo "βœ… Version matches package.json: $PKG_VERSION" echo "TAG_VERSION=$TAG" >> $GITHUB_ENV - name: Setup Node.js