From 35190585d769f44186924b60810934658805bbfa Mon Sep 17 00:00:00 2001 From: coo1estguy <70365929+coolestcodeguy@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:02:46 +0530 Subject: [PATCH] feat(client): support dynamic contacts array, ergonomic addUser signature, and strong SDK types --- README.md | 4 +- package.json | 2 +- packages/mcp/package.json | 2 +- packages/mcp/src/tools.ts | 30 +++++++ src/client.ts | 151 +++++++++++++++++++++++++---------- src/contracts/sdk.ts | 146 ++++++++++++++++++++++++++++++++- src/services/api/handlers.ts | 124 +++++++++++++++++++++------- tests/api.test.ts | 62 ++++++++++++++ tests/client.test.ts | 70 ++++++++++++++-- tests/workers.test.ts | 38 +++++++++ 10 files changed, 547 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 9db05f5..0099dbd 100644 --- a/README.md +++ b/README.md @@ -175,8 +175,8 @@ await notifkit.syncTemplates({ ], }); -// 2. Register a user -await notifkit.addUser({ id: "usr_123", email: "alex@acme.com" }); +// 2. Register a user (supports id + contacts array or object) +await notifkit.addUser("usr_123", [{ channel: "email", target: "alex@acme.com" }]); // 3. Dispatch await notifkit.notify({ diff --git a/package.json b/package.json index 0b08b99..1ce5a60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "notifkit", - "version": "0.1.6", + "version": "0.1.7", "description": "Self-hosted notification infrastructure. One call delivers to email, SMS, push, and webhook — routed by preference, quiet hours, and consent.", "license": "MIT", "author": "devkitshq", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 933f716..65d7912 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@notifkit/mcp", - "version": "0.1.2", + "version": "0.1.3", "description": "Model Context Protocol server for notifkit — drive sends, templates, users, and workflows from a terminal agent.", "type": "module", "main": "dist/index.js", diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index 8a72e6c..977ece9 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -891,6 +891,22 @@ export function registerTools(server: McpServer, api: NotifkitApi): void { "`segment` targeting matches on. Timezone drives quiet-hours maths, so set it when known.", inputSchema: { id: z.string().min(1).describe("Your own stable user id, e.g. 'usr_123'."), + contacts: z + .array( + z.object({ + channel: z.enum(CHANNELS).describe("Contact delivery channel."), + target: z.string().min(1).describe("Destination address, phone, token, or URL."), + label: z.string().optional().describe("Friendly label for this contact."), + isPrimary: z + .boolean() + .optional() + .describe("Whether this is the primary contact for this channel."), + }), + ) + .optional() + .describe( + "Array of user contacts across any supported channels (preferred over legacy email/phone/pushToken).", + ), email: z .union([z.string().min(1), z.array(z.string().min(1))]) .optional() @@ -948,6 +964,20 @@ export function registerTools(server: McpServer, api: NotifkitApi): void { "Partially update an existing user profile (e.g. language, timezone, segment tags, preferences, or append new contacts) without replacing unspecified fields.", inputSchema: { id: z.string().min(1).describe("User id."), + contacts: z + .array( + z.object({ + channel: z.enum(CHANNELS).describe("Contact delivery channel."), + target: z.string().min(1).describe("Destination address, phone, token, or URL."), + label: z.string().optional().describe("Friendly label for this contact."), + isPrimary: z + .boolean() + .optional() + .describe("Whether this is the primary contact for this channel."), + }), + ) + .optional() + .describe("Array of user contacts across any supported channels to append or update."), email: z .union([z.string().min(1), z.array(z.string().min(1))]) .optional() diff --git a/src/client.ts b/src/client.ts index a2ed6b1..f33a5d3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2,12 +2,29 @@ import type { AddUserInput, UpdateUserInput, AddContactInput, + UserContactInput, SyncTemplatesInput, + TemplateInput, NotifyRequestInput, TriggerWorkflowInput, CreateWorkflowInput, IngestEventInput, UpdateProjectInput, + Preferences, + UserContactResponse, + UserResponse, + UserDetailResponse, + UserProfileResponse, + WorkflowDefinitionRecord, + WorkflowInstanceRecord, + NotificationLogRecord, + TemplateRecordResponse, + SuppressionRecord, + ProjectRecord, + ProjectKeyRecord, + SystemHealthRecord, + SystemMetricsRecord, + DLQMessageRecord, } from "./contracts/sdk.js"; export interface NotifkitClientOptions { @@ -53,37 +70,86 @@ export class NotifkitClient { return data as T; } - /** Sync templates with the server. */ - async syncTemplates(input: SyncTemplatesInput): Promise<{ synced: number }> { - return this.request("/v1/templates", "PUT", input); + /** Sync templates with the server. Accepts either { templates: [...] } or an array of templates directly. */ + async syncTemplates(input: SyncTemplatesInput | TemplateInput[]): Promise<{ synced: number }> { + const payload = Array.isArray(input) ? { templates: input } : input; + return this.request("/v1/templates", "PUT", payload); } - /** Create/upsert a user profile and contacts. */ - async addUser(input: AddUserInput): Promise<{ id: string }> { - return this.request("/v1/users", "POST", input); + /** + * Create/upsert a user profile and contacts. + * Can be called with (id, contacts, options) or (inputObject). + */ + async addUser( + id: string, + contacts?: UserContactInput[], + options?: Omit, + ): Promise<{ id: string }>; + async addUser(input: AddUserInput): Promise<{ id: string }>; + async addUser( + idOrInput: string | AddUserInput, + contacts?: UserContactInput[], + options?: Omit, + ): Promise<{ id: string }> { + if (typeof idOrInput === "string") { + return this.request("/v1/users", "POST", { + id: idOrInput, + contacts: contacts ?? [], + ...options, + }); + } + return this.request("/v1/users", "POST", idOrInput); + } + + /** + * Convenient alias for `addUser` matching standard CDP/notification SDK conventions. + */ + async identify( + id: string, + contacts?: UserContactInput[], + options?: Omit, + ): Promise<{ id: string }>; + async identify(input: AddUserInput): Promise<{ id: string }>; + async identify( + idOrInput: string | AddUserInput, + contacts?: UserContactInput[], + options?: Omit, + ): Promise<{ id: string }> { + return (this.addUser as any)(idOrInput, contacts, options); } /** Update user profile. */ async updateUser(id: string, input: UpdateUserInput): Promise<{ id: string }> { - return this.request(`/v1/users/${id}`, "PATCH", input); + return this.request(`/v1/users/${encodeURIComponent(id)}`, "PATCH", input); } /** Delete user profile. */ async deleteUser(id: string): Promise { - return this.request(`/v1/users/${id}`, "DELETE"); + return this.request(`/v1/users/${encodeURIComponent(id)}`, "DELETE"); } - /** Add contact targets to user profile. */ + /** Add contact target to user profile. */ async addContact( userId: string, input: AddContactInput, ): Promise<{ userId: string; channel: string; target: string }> { - return this.request(`/v1/users/${userId}/contacts`, "POST", input); + return this.request(`/v1/users/${encodeURIComponent(userId)}/contacts`, "POST", input); + } + + /** Add multiple contact targets to user profile in a batch. */ + async addContacts( + userId: string, + contacts: AddContactInput[], + ): Promise<{ userId: string; contacts: Array<{ channel: string; target: string }> }> { + return this.request(`/v1/users/${encodeURIComponent(userId)}/contacts`, "POST", contacts); } /** Delete a specific contact channel target. */ async deleteContact(userId: string, channel: string, target: string): Promise { - return this.request(`/v1/users/${userId}/contacts/${channel}/${target}`, "DELETE"); + return this.request( + `/v1/users/${encodeURIComponent(userId)}/contacts/${encodeURIComponent(channel)}/${encodeURIComponent(target)}`, + "DELETE", + ); } /** Request a notification dispatch. */ @@ -124,7 +190,7 @@ export class NotifkitClient { async listWorkflows(options?: { limit?: number; search?: string; - }): Promise<{ workflows: any[] }> { + }): Promise<{ workflows: WorkflowDefinitionRecord[] }> { const params = new URLSearchParams(); if (options?.limit) params.set("limit", options.limit.toString()); if (options?.search) params.set("search", options.search); @@ -133,13 +199,13 @@ export class NotifkitClient { } /** Get a workflow instance by ID. */ - async getWorkflow(instanceId: string): Promise { - return this.request(`/v1/workflows/instances/${instanceId}`, "GET"); + async getWorkflow(instanceId: string): Promise { + return this.request(`/v1/workflows/instances/${encodeURIComponent(instanceId)}`, "GET"); } /** Cancel a running/suspended workflow instance. */ async cancelWorkflow(instanceId: string): Promise { - return this.request(`/v1/workflows/instances/${instanceId}`, "DELETE"); + return this.request(`/v1/workflows/instances/${encodeURIComponent(instanceId)}`, "DELETE"); } /** Get notification logs for the project. */ @@ -153,7 +219,7 @@ export class NotifkitClient { taskId?: string; campaign?: string; search?: string; - }): Promise<{ logs: any[]; nextCursor: string | null }> { + }): Promise<{ logs: NotificationLogRecord[]; nextCursor: string | null }> { let url = "/v1/notifications/logs"; if (options) { const params = new URLSearchParams(); @@ -182,7 +248,7 @@ export class NotifkitClient { language?: string; timezone?: string; channel?: string; - }): Promise<{ users: any[]; nextCursor: string | null }> { + }): Promise<{ users: UserProfileResponse[]; nextCursor: string | null }> { const params = new URLSearchParams(); if (options?.limit) params.set("limit", options.limit.toString()); if (options?.cursor) params.set("cursor", options.cursor); @@ -197,45 +263,48 @@ export class NotifkitClient { /** Delete a template. */ async deleteTemplate(id: string): Promise { - return this.request(`/v1/templates/${id}`, "DELETE"); + return this.request(`/v1/templates/${encodeURIComponent(id)}`, "DELETE"); } /** Get a user's contacts. */ - async getUserContacts(userId: string): Promise<{ contacts: any[] }> { - return this.request(`/v1/users/${userId}/contacts`, "GET"); + async getUserContacts(userId: string): Promise<{ contacts: UserContactResponse[] }> { + return this.request(`/v1/users/${encodeURIComponent(userId)}/contacts`, "GET"); } /** List projects (Admin only). */ - async listProjects(): Promise<{ projects: any[] }> { + async listProjects(): Promise<{ projects: ProjectRecord[] }> { return this.request("/v1/projects", "GET"); } /** Delete a project (Admin only). */ async deleteProject(id: string): Promise { - return this.request(`/v1/projects/${id}`, "DELETE"); + return this.request(`/v1/projects/${encodeURIComponent(id)}`, "DELETE"); } /** Create a new project API key (Admin only). */ async createProjectKey( id: string, input?: { role?: "admin" | "read_only" }, - ): Promise<{ id: string; apiKey: string; role: string }> { - return this.request(`/v1/projects/${id}/keys`, "POST", input || {}); + ): Promise { + return this.request(`/v1/projects/${encodeURIComponent(id)}/keys`, "POST", input || {}); } /** List project API keys (Admin only). */ - async listProjectKeys(id: string): Promise<{ keys: any[] }> { - return this.request(`/v1/projects/${id}/keys`, "GET"); + async listProjectKeys(id: string): Promise<{ keys: ProjectKeyRecord[] }> { + return this.request(`/v1/projects/${encodeURIComponent(id)}/keys`, "GET"); } /** Delete a project API key (Admin only). */ async deleteProjectKey(id: string, keyId: string): Promise { - return this.request(`/v1/projects/${id}/keys/${keyId}`, "DELETE"); + return this.request( + `/v1/projects/${encodeURIComponent(id)}/keys/${encodeURIComponent(keyId)}`, + "DELETE", + ); } /** Update project settings (Admin only). */ async updateProject(id: string, input: UpdateProjectInput): Promise<{ id: string }> { - return this.request(`/v1/projects/${id}`, "PATCH", input); + return this.request(`/v1/projects/${encodeURIComponent(id)}`, "PATCH", input); } /** List unique segment tags. */ @@ -302,7 +371,7 @@ export class NotifkitClient { channel?: string; reason?: string; target?: string; - }): Promise<{ suppressions: any[] }> { + }): Promise<{ suppressions: SuppressionRecord[] }> { const params = new URLSearchParams(); if (options?.limit) params.set("limit", options.limit.toString()); if (options?.channel) params.set("channel", options.channel); @@ -332,7 +401,9 @@ export class NotifkitClient { // ─── Notification Status & Cancellation ───────────────────────────────────── /** Get real-time status and delivery logs for a specific notification task. */ - async getNotificationStatus(taskId: string): Promise<{ status: string; logs: any[] }> { + async getNotificationStatus( + taskId: string, + ): Promise<{ status: string; logs: NotificationLogRecord[] }> { return this.request(`/v1/notifications/${encodeURIComponent(taskId)}`, "GET"); } @@ -349,54 +420,54 @@ export class NotifkitClient { // ─── User Profile & Preferences ───────────────────────────────────────────── /** Get user profile and contacts by ID. */ - async getUser(id: string): Promise { + async getUser(id: string): Promise { return this.request(`/v1/users/${encodeURIComponent(id)}`, "GET"); } /** Get user details including contacts and recent message logs. */ - async getUserDetails(id: string): Promise { + async getUserDetails(id: string): Promise { return this.request(`/v1/users/${encodeURIComponent(id)}/details`, "GET"); } /** Get user preferences. */ - async getUserPreferences(id: string): Promise { + async getUserPreferences(id: string): Promise { return this.request(`/v1/users/${encodeURIComponent(id)}/preferences`, "GET"); } /** Update user preferences. */ async updateUserPreferences( id: string, - preferences: Record, - ): Promise<{ id: string; preferences: any }> { + preferences: Preferences, + ): Promise<{ id: string; preferences: Preferences }> { return this.request(`/v1/users/${encodeURIComponent(id)}/preferences`, "PATCH", preferences); } // ─── Templates Querying ───────────────────────────────────────────────────── /** List all templates for the project. */ - async listTemplates(): Promise<{ templates: any[] }> { + async listTemplates(): Promise<{ templates: TemplateRecordResponse[] }> { return this.request("/v1/templates", "GET"); } /** Get a template by ID. */ - async getTemplate(id: string): Promise { + async getTemplate(id: string): Promise { return this.request(`/v1/templates/${encodeURIComponent(id)}`, "GET"); } // ─── System Health, Metrics & DLQ ─────────────────────────────────────────── /** Get system health and worker status. */ - async getSystemHealth(): Promise { + async getSystemHealth(): Promise { return this.request("/v1/system/health", "GET"); } /** Get system metrics and queue lengths. */ - async getSystemMetrics(): Promise { + async getSystemMetrics(): Promise { return this.request("/v1/system/metrics", "GET"); } /** Get dead-letter queue messages. */ - async getDLQMessages(): Promise<{ messages: any[] }> { + async getDLQMessages(): Promise<{ messages: DLQMessageRecord[] }> { return this.request("/v1/dlq", "GET"); } diff --git a/src/contracts/sdk.ts b/src/contracts/sdk.ts index 1844e7f..d22509a 100644 --- a/src/contracts/sdk.ts +++ b/src/contracts/sdk.ts @@ -28,6 +28,7 @@ export const ContactChannelSchema = z.enum([ "sms", "push", "webhook", + "in-app", "telegram", "discord", "whatsapp", @@ -40,16 +41,28 @@ const stringOrArray = z .union([z.string().min(1), z.array(z.string().min(1))]) .transform((v) => (Array.isArray(v) ? v : [v])); +export const UserContactInputSchema = z.object({ + channel: ContactChannelSchema, + target: z.string().min(1), + label: z.string().optional(), + isPrimary: z.boolean().optional(), + enabled: z.boolean().optional(), + preferences: PreferencesSchema.optional(), +}); +export type UserContactInput = z.infer; + // ─── Users ──────────────────────────────────────────────────────────────────── /** - * addUser({ id, email, phone, pushToken, segments, preferences }) - * email / phone / pushToken accept a single string or an array. + * addUser({ id, contacts, email, phone, pushToken, segments, preferences }) + * email / phone / pushToken accept a single string or an array for backwards compatibility. + * Prefer `contacts: [{ channel, target }]`. */ export const AddUserSchema = z.object({ id: z.string().min(1), language: z.string().optional(), timezone: z.string().optional(), + contacts: z.array(UserContactInputSchema).optional(), email: stringOrArray.optional(), phone: stringOrArray.optional(), pushToken: stringOrArray.optional(), @@ -62,6 +75,7 @@ export type AddUserInput = z.input; export const UpdateUserSchema = z.object({ language: z.string().optional(), timezone: z.string().optional(), + contacts: z.array(UserContactInputSchema).optional(), email: stringOrArray.optional(), phone: stringOrArray.optional(), pushToken: stringOrArray.optional(), @@ -70,14 +84,20 @@ export const UpdateUserSchema = z.object({ }); export type UpdateUserInput = z.input; -/** addUserContact(userId, channel, { target, preferences }) — channel carried in body. */ +/** addUserContact(userId, channel, { target, preferences, label, isPrimary, enabled }) */ export const AddContactSchema = z.object({ channel: ContactChannelSchema, target: z.string().min(1), + label: z.string().optional(), + isPrimary: z.boolean().optional(), + enabled: z.boolean().optional(), preferences: PreferencesSchema.optional(), }); export type AddContactInput = z.infer; +export const BatchAddContactsSchema = z.union([AddContactSchema, z.array(AddContactSchema).min(1)]); +export type BatchAddContactsInput = z.infer; + // ─── Templates ──────────────────────────────────────────────────────────────── export const TemplateSchema = z.object({ @@ -104,6 +124,7 @@ export const InlineUserSchema = z.object({ id: z.string().min(1), language: z.string().optional(), timezone: z.string().optional(), + contacts: z.array(UserContactInputSchema).optional(), email: stringOrArray.optional(), phone: stringOrArray.optional(), pushToken: stringOrArray.optional(), @@ -240,3 +261,122 @@ export const UpdateProjectSchema = z.object({ throttleWindowHours: z.number().nullable().optional(), }); export type UpdateProjectInput = z.infer; + +// ─── Response Entities ──────────────────────────────────────────────────────── + +export interface UserContactResponse { + id: string; + userId: string; + channel: ContactChannel; + target: string; + label?: string | null; + isPrimary?: boolean; + active?: boolean; + enabled?: boolean; + preferences?: Preferences; +} + +export interface UserProfileResponse { + userId: string; + language?: string; + timezone?: string; + email?: string | null; +} + +export interface UserResponse extends UserProfileResponse { + segments: string[]; + preferences: Preferences; + contacts?: UserContactResponse[]; +} + +export interface UserDetailResponse extends UserResponse { + contacts: UserContactResponse[]; + recentLogs?: unknown[]; +} + +export interface WorkflowDefinitionRecord { + id: string; + name: string; + steps: unknown[]; + createdAt?: string; + updatedAt?: string; +} + +export interface WorkflowInstanceRecord { + id: string; + name: string; + status: string; + currentStepIndex: number; + input?: Record; + output?: Record; + createdAt: string; + updatedAt: string; +} + +export interface NotificationLogRecord { + id: string; + notificationId: string; + taskId?: string; + channel: string; + target?: string; + status: string; + templateId?: string; + campaign?: string; + createdAt: string; + dispatchedAt?: string; + error?: string; + metadata?: Record; +} + +export interface TemplateRecordResponse { + id: string; + channel: string; + topics: string[]; + content: Record; + aiPrompts?: Record | null; +} + +export interface SuppressionRecord { + id: string; + channel: string; + target: string; + reason: string; + createdAt: string; +} + +export interface ProjectRecord { + id: string; + name: string; + rateLimitRpm?: number | null; + throttleLimit?: number | null; + throttleWindowHours?: number | null; + createdAt: string; +} + +export interface ProjectKeyRecord { + id: string; + apiKey: string; + role: string; + createdAt: string; +} + +export interface SystemHealthRecord { + status: string; + timestamp: string; + redis?: { status: string }; + db?: { status: string }; + workers?: Record; +} + +export interface SystemMetricsRecord { + queues: Record; + throughput?: Record; +} + +export interface DLQMessageRecord { + id: string; + stream: string; + payload: Record; + error?: string; + timestamp: string; +} diff --git a/src/services/api/handlers.ts b/src/services/api/handlers.ts index 76d57d9..15220a8 100644 --- a/src/services/api/handlers.ts +++ b/src/services/api/handlers.ts @@ -15,10 +15,12 @@ import type { Preferences } from "@/contracts/index.js"; import { AddUserSchema, UpdateUserSchema, - AddContactSchema, + BatchAddContactsSchema, + type UserContactInput, SyncTemplatesSchema, NotifyRequestSchema, ContactChannelSchema, + type ContactChannel, PreferencesSchema, type NotificationRequestedPayload, type NotificationTarget, @@ -69,6 +71,7 @@ interface InlineUserLike { id: string; language?: string; timezone?: string; + contacts?: UserContactInput[]; email?: string[]; phone?: string[]; pushToken?: string[]; @@ -76,25 +79,66 @@ interface InlineUserLike { preferences?: Preferences; } -/** Map an inline user's contact arrays into (channel, target) pairs. */ -function contactsOf(user: InlineUserLike): { channel: "email" | "sms" | "push"; target: string }[] { - return [ - ...(user.email ?? []).map((target) => ({ channel: "email" as const, target })), - ...(user.phone ?? []).map((target) => ({ channel: "sms" as const, target })), - ...(user.pushToken ?? []).map((target) => ({ channel: "push" as const, target })), - ]; +interface ResolvedContact { + channel: ContactChannel; + target: string; + label?: string; + isPrimary?: boolean; + enabled?: boolean; + preferences?: Preferences; +} + +/** Map an inline or user input's contacts and legacy arrays into resolved contact records. */ +function contactsOf(user: InlineUserLike): ResolvedContact[] { + const list: ResolvedContact[] = []; + + if (user.contacts && Array.isArray(user.contacts)) { + for (const c of user.contacts) { + list.push({ + channel: c.channel, + target: c.target, + label: c.label, + isPrimary: c.isPrimary, + enabled: c.enabled, + preferences: c.preferences, + }); + } + } + + if (user.email) { + for (const target of user.email) { + list.push({ channel: "email", target }); + } + } + if (user.phone) { + for (const target of user.phone) { + list.push({ channel: "sms", target }); + } + } + if (user.pushToken) { + for (const target of user.pushToken) { + list.push({ channel: "push", target }); + } + } + + return list; } /** Persist user records + their contacts in bulk. Shared by addUser and inline notify. */ async function persistUsers(deps: Deps, users: InlineUserLike[], projectId: string): Promise { - const usersList = users.map((u) => ({ - userId: u.id, - language: u.language ?? "en", - timezone: u.timezone ?? "UTC", - email: u.email?.[0] ?? null, - segments: u.segments ?? [], - preferences: u.preferences ?? {}, - })); + const usersList = users.map((u) => { + const primaryEmail = + u.email?.[0] ?? u.contacts?.find((c) => c.channel === "email")?.target ?? null; + + return { + userId: u.id, + language: u.language ?? "en", + timezone: u.timezone ?? "UTC", + email: primaryEmail, + segments: u.segments ?? [], + preferences: u.preferences ?? {}, + }; + }); const contactsList: any[] = []; for (const u of users) { @@ -103,7 +147,7 @@ async function persistUsers(deps: Deps, users: InlineUserLike[], projectId: stri userId: u.id, channel: c.channel, target: c.target, - preferences: {}, + preferences: c.preferences ?? {}, }); } } @@ -190,7 +234,7 @@ export function createHandlers(deps: Deps) { const updated = await deps.userRepo.updatePartial(ctx.projectId!, userId, { language: patch.language, timezone: patch.timezone, - email: patch.email?.[0], + email: patch.email?.[0] ?? patch.contacts?.find((c) => c.channel === "email")?.target, segments: patch.segments, preferences: patch.preferences, }); @@ -198,7 +242,11 @@ export function createHandlers(deps: Deps) { // New contact values in the patch are added (existing ones are kept). for (const c of contactsOf({ id: userId, ...patch })) { - await deps.contactRepo.upsert(ctx.projectId!, userId, c.channel, c.target); + if (c.preferences) { + await deps.contactRepo.upsert(ctx.projectId!, userId, c.channel, c.target, c.preferences); + } else { + await deps.contactRepo.upsert(ctx.projectId!, userId, c.channel, c.target); + } } logger.info({ userId }, "user updated"); sendJson(res, 200, { id: userId }); @@ -217,28 +265,44 @@ export function createHandlers(deps: Deps) { sendNoContent(res); } - // ── POST /v1/users/:id/contacts — addUserContact ────────────────────────── + // ── POST /v1/users/:id/contacts — addUserContact / batch contacts ───────── async function addContact( req: IncomingMessage, res: ServerResponse, ctx: RouteContext, ): Promise { const userId = ctx.params.id!; - const parsed = AddContactSchema.safeParse(await readJsonBody(req)); + const body = await readJsonBody(req); + const parsed = BatchAddContactsSchema.safeParse(body); if (!parsed.success) return sendValidationError(res, parsed.error); const user = await deps.userRepo.findById(ctx.projectId!, userId); if (!user) return sendJson(res, 404, { error: "user_not_found", id: userId }); - await deps.contactRepo.upsert( - ctx.projectId!, - userId, - parsed.data.channel, - parsed.data.target, - parsed.data.preferences ?? {}, - ); - logger.info({ userId, channel: parsed.data.channel }, "contact added"); - sendJson(res, 201, { userId, channel: parsed.data.channel, target: parsed.data.target }); + if (Array.isArray(parsed.data)) { + const contactsList = parsed.data.map((c) => ({ + userId, + channel: c.channel, + target: c.target, + preferences: c.preferences ?? {}, + })); + await deps.contactRepo.upsertMany(ctx.projectId!, contactsList); + logger.info({ userId, count: contactsList.length }, "contacts batch added"); + sendJson(res, 201, { + userId, + contacts: contactsList.map((c) => ({ channel: c.channel, target: c.target })), + }); + } else { + await deps.contactRepo.upsert( + ctx.projectId!, + userId, + parsed.data.channel, + parsed.data.target, + parsed.data.preferences ?? {}, + ); + logger.info({ userId, channel: parsed.data.channel }, "contact added"); + sendJson(res, 201, { userId, channel: parsed.data.channel, target: parsed.data.target }); + } } // ── DELETE /v1/users/:id/contacts/:channel/:target — deleteUserContact ──── diff --git a/tests/api.test.ts b/tests/api.test.ts index 7017ae8..b0fcd02 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -302,6 +302,68 @@ describe("API Handlers", () => { ); }); + it("addUser with dynamic contacts array", async () => { + const req = createMockReq({ + id: "usr_dynamic", + contacts: [ + { channel: "slack", target: "U999", label: "Work Slack" }, + { channel: "discord", target: "888" }, + { channel: "email", target: "slackuser@example.com" }, + ], + segments: ["power-users"], + }); + const res = createMockRes(); + + await handlers.addUser(req, res, { + projectId: "test_project_id", + params: {}, + query: new URLSearchParams(), + } as any); + + expect(res.statusCode).toBe(201); + expect(deps.userRepo.upsertManyFull).toHaveBeenCalledWith("test_project_id", [ + expect.objectContaining({ + userId: "usr_dynamic", + email: "slackuser@example.com", // falls back to email in contacts array + segments: ["power-users"], + }), + ]); + expect(deps.contactRepo.upsertMany).toHaveBeenCalledWith("test_project_id", [ + expect.objectContaining({ userId: "usr_dynamic", channel: "slack", target: "U999" }), + expect.objectContaining({ userId: "usr_dynamic", channel: "discord", target: "888" }), + expect.objectContaining({ + userId: "usr_dynamic", + channel: "email", + target: "slackuser@example.com", + }), + ]); + }); + + it("addContact batch support", async () => { + const req = createMockReq([ + { channel: "webhook", target: "https://example.com/webhook" }, + { channel: "telegram", target: "12345678" }, + ]); + const res = createMockRes(); + const ctx = { params: { id: "usr_456" }, query: new URLSearchParams() }; + + await handlers.addContact(req, res, { ...ctx, projectId: "test_project_id" } as any); + + expect(res.statusCode).toBe(201); + expect(deps.contactRepo.upsertMany).toHaveBeenCalledWith("test_project_id", [ + expect.objectContaining({ + userId: "usr_456", + channel: "webhook", + target: "https://example.com/webhook", + }), + expect.objectContaining({ + userId: "usr_456", + channel: "telegram", + target: "12345678", + }), + ]); + }); + it("deleteContact (deleteUserContact)", async () => { const req = createMockReq({}); const res = createMockRes(); diff --git a/tests/client.test.ts b/tests/client.test.ts index 736d178..3df9375 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -121,7 +121,7 @@ describe("NotifkitClient", () => { }); describe("users", () => { - it("addUser POSTs to /v1/users", async () => { + it("addUser POSTs to /v1/users with object input", async () => { const fetchMock = stubFetch({ id: "usr_1" }); await client().addUser({ id: "usr_1" }); @@ -130,6 +130,44 @@ describe("NotifkitClient", () => { expect(init.method).toBe("POST"); }); + it("addUser POSTs to /v1/users with (id, contacts, options) signature", async () => { + const fetchMock = stubFetch({ id: "usr_1" }); + await client().addUser( + "usr_1", + [ + { channel: "email", target: "alice@example.com" }, + { channel: "slack", target: "U12345" }, + ], + { timezone: "America/New_York", segments: ["early-adopters"] }, + ); + + const { url, init } = callArgs(fetchMock); + expect(url).toBe("https://api.test/v1/users"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ + id: "usr_1", + contacts: [ + { channel: "email", target: "alice@example.com" }, + { channel: "slack", target: "U12345" }, + ], + timezone: "America/New_York", + segments: ["early-adopters"], + }); + }); + + it("identify aliases addUser", async () => { + const fetchMock = stubFetch({ id: "usr_1" }); + await client().identify("usr_1", [{ channel: "sms", target: "+15551234567" }]); + + const { url, init } = callArgs(fetchMock); + expect(url).toBe("https://api.test/v1/users"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ + id: "usr_1", + contacts: [{ channel: "sms", target: "+15551234567" }], + }); + }); + it("updateUser PATCHes the user path", async () => { const fetchMock = stubFetch({ id: "usr_1" }); await client().updateUser("usr_1", { timezone: "UTC" }); @@ -178,12 +216,34 @@ describe("NotifkitClient", () => { expect(init.method).toBe("POST"); }); - it("deleteContact targets the specific channel and address", async () => { - const fetchMock = stubFetch(undefined, 204); - await client().deleteContact("usr_1", "email", "a@b.co"); + it("addContacts POSTs batch of contacts to the contacts path", async () => { + const fetchMock = stubFetch({ + userId: "usr_1", + contacts: [ + { channel: "email", target: "a@b.co" }, + { channel: "slack", target: "U123" }, + ], + }); + await client().addContacts("usr_1", [ + { channel: "email", target: "a@b.co" }, + { channel: "slack", target: "U123" }, + ]); const { url, init } = callArgs(fetchMock); - expect(url).toBe("https://api.test/v1/users/usr_1/contacts/email/a@b.co"); + expect(url).toBe("https://api.test/v1/users/usr_1/contacts"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual([ + { channel: "email", target: "a@b.co" }, + { channel: "slack", target: "U123" }, + ]); + }); + + it("deleteContact targets the specific channel and URI-encoded address", async () => { + const fetchMock = stubFetch(undefined, 204); + await client().deleteContact("usr_1", "email", "a+tag@b.co"); + + const { url, init } = callArgs(fetchMock); + expect(url).toBe("https://api.test/v1/users/usr_1/contacts/email/a%2Btag%40b.co"); expect(init.method).toBe("DELETE"); }); }); diff --git a/tests/workers.test.ts b/tests/workers.test.ts index c45bc20..1df6d9c 100644 --- a/tests/workers.test.ts +++ b/tests/workers.test.ts @@ -762,6 +762,44 @@ describe("EnricherWorker", () => { const enriched = mockProducers.normal.publishBatch.mock.calls[0][0][0].payload; expect(enriched.recipient.preferences.optedOut).toBe(true); }); + + it("resolves multi-channel contacts (e.g. slack, webhook) from contactRepo", async () => { + (worker as any).contactRepo.findActiveByUserIds = vi.fn().mockResolvedValue( + new Map([ + [ + "usr-1", + [ + { channel: "slack", target: "U12345", enabled: true }, + { channel: "webhook", target: "https://hook.site/test", enabled: true }, + ], + ], + ]), + ); + + const msg = { + id: "msg-dynamic-channels", + event: { + id: "evt-dynamic-channels", + type: "notification.requested", + timestamp: new Date().toISOString(), + metadata: { traceId: "trace-dc" }, + payload: { + projectId: "123e4567-e89b-12d3-a456-426614174000", + target: { type: "user", userId: "usr-1" }, + channels: ["slack", "webhook"], + templateId: "welcome_alert", + }, + }, + }; + + await worker.process(msg as any); + + expect(mockProducers.normal.publishBatch).toHaveBeenCalled(); + const publishedEvents = mockProducers.normal.publishBatch.mock.calls[0][0]; + const channels = publishedEvents.map((e: any) => e.payload.channel); + expect(channels).toContain("slack"); + expect(channels).toContain("webhook"); + }); }); describe("DeliveryWorker", () => {