feat(client): support dynamic contacts array, ergonomic addUser signature, and strong SDK types

This commit is contained in:
coo1estguy
2026-09-13 00:02:46 +05:30
parent 1c728a223c
commit 35190585d7
10 changed files with 547 additions and 82 deletions
+2 -2
View File
@@ -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({
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+30
View File
@@ -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()
+111 -40
View File
@@ -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<AddUserInput, "id" | "contacts">,
): Promise<{ id: string }>;
async addUser(input: AddUserInput): Promise<{ id: string }>;
async addUser(
idOrInput: string | AddUserInput,
contacts?: UserContactInput[],
options?: Omit<AddUserInput, "id" | "contacts">,
): 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<AddUserInput, "id" | "contacts">,
): Promise<{ id: string }>;
async identify(input: AddUserInput): Promise<{ id: string }>;
async identify(
idOrInput: string | AddUserInput,
contacts?: UserContactInput[],
options?: Omit<AddUserInput, "id" | "contacts">,
): 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<void> {
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<void> {
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<any> {
return this.request(`/v1/workflows/instances/${instanceId}`, "GET");
async getWorkflow(instanceId: string): Promise<WorkflowInstanceRecord> {
return this.request(`/v1/workflows/instances/${encodeURIComponent(instanceId)}`, "GET");
}
/** Cancel a running/suspended workflow instance. */
async cancelWorkflow(instanceId: string): Promise<void> {
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<void> {
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<void> {
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<ProjectKeyRecord> {
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<void> {
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<any> {
async getUser(id: string): Promise<UserResponse> {
return this.request(`/v1/users/${encodeURIComponent(id)}`, "GET");
}
/** Get user details including contacts and recent message logs. */
async getUserDetails(id: string): Promise<any> {
async getUserDetails(id: string): Promise<UserDetailResponse> {
return this.request(`/v1/users/${encodeURIComponent(id)}/details`, "GET");
}
/** Get user preferences. */
async getUserPreferences(id: string): Promise<any> {
async getUserPreferences(id: string): Promise<Preferences> {
return this.request(`/v1/users/${encodeURIComponent(id)}/preferences`, "GET");
}
/** Update user preferences. */
async updateUserPreferences(
id: string,
preferences: Record<string, any>,
): 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<any> {
async getTemplate(id: string): Promise<TemplateRecordResponse> {
return this.request(`/v1/templates/${encodeURIComponent(id)}`, "GET");
}
// ─── System Health, Metrics & DLQ ───────────────────────────────────────────
/** Get system health and worker status. */
async getSystemHealth(): Promise<any> {
async getSystemHealth(): Promise<SystemHealthRecord> {
return this.request("/v1/system/health", "GET");
}
/** Get system metrics and queue lengths. */
async getSystemMetrics(): Promise<any> {
async getSystemMetrics(): Promise<SystemMetricsRecord> {
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");
}
+143 -3
View File
@@ -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<typeof UserContactInputSchema>;
// ─── 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<typeof AddUserSchema>;
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<typeof UpdateUserSchema>;
/** 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<typeof AddContactSchema>;
export const BatchAddContactsSchema = z.union([AddContactSchema, z.array(AddContactSchema).min(1)]);
export type BatchAddContactsInput = z.infer<typeof BatchAddContactsSchema>;
// ─── 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<typeof UpdateProjectSchema>;
// ─── 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<string, unknown>;
output?: Record<string, unknown>;
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<string, unknown>;
}
export interface TemplateRecordResponse {
id: string;
channel: string;
topics: string[];
content: Record<string, unknown>;
aiPrompts?: Record<string, string> | 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<string, unknown>;
}
export interface SystemMetricsRecord {
queues: Record<string, number>;
throughput?: Record<string, unknown>;
}
export interface DLQMessageRecord {
id: string;
stream: string;
payload: Record<string, unknown>;
error?: string;
timestamp: string;
}
+94 -30
View File
@@ -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<void> {
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<void> {
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 ────
+62
View File
@@ -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();
+65 -5
View File
@@ -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");
});
});
+38
View File
@@ -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", () => {