mirror of
https://github.com/devkitshq/notifkit.git
synced 2026-10-19 18:24:52 +02:00
feat(client): support dynamic contacts array, ergonomic addUser signature, and strong SDK types
This commit is contained in:
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ────
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user