Add ntfy.sh

This commit is contained in:
Dries Augustyns
2025-12-04 17:34:24 +01:00
parent 726493a1ed
commit c114feb32e
15 changed files with 1070 additions and 19 deletions
+4
View File
@@ -7,6 +7,7 @@ import {prisma} from '../database/prisma.js';
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
import {jwt} from '../middleware/auth.js';
import {AuthService} from '../services/AuthService.js';
import {NtfyService} from '../services/NtfyService.js';
import {UserService} from '../services/UserService.js';
import {Keys} from '../services/keys.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -68,6 +69,9 @@ export class Auth {
await redis.set(Keys.User.id(created_user.id), JSON.stringify(created_user), 'EX', REDIS_ONE_MINUTE * 60);
// Send notification about new user signup
await NtfyService.notifyUserSignup(created_user.email, created_user.id);
const token = jwt.sign(created_user.id);
const cookie = UserService.cookieOptions();
+8
View File
@@ -10,6 +10,7 @@ import {
} from '../../app/constants.js';
import {prisma} from '../../database/prisma.js';
import {jwt} from '../../middleware/auth.js';
import {NtfyService} from '../../services/NtfyService.js';
import {UserService} from '../../services/UserService.js';
import {CatchAsync} from '../../utils/asyncHandler.js';
@@ -59,6 +60,7 @@ export class Github {
const email = emails.find((e: {primary: boolean; email: string}) => e.primary).email;
let user = await UserService.email(email as string);
let isNewUser = false;
if (!user) {
user = await prisma.user.create({
@@ -67,12 +69,18 @@ export class Github {
type: 'GITHUB_OAUTH',
},
});
isNewUser = true;
}
if (user.type !== 'GITHUB_OAUTH') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=You used another form of authentication');
}
// Send notification if this is a new user
if (isNewUser) {
await NtfyService.notifyUserOAuthSignup(user.email, user.id, 'GitHub');
}
const token = jwt.sign(user.id);
const cookie = UserService.cookieOptions();
+8
View File
@@ -10,6 +10,7 @@ import {
} from '../../app/constants.js';
import {prisma} from '../../database/prisma.js';
import {jwt} from '../../middleware/auth.js';
import {NtfyService} from '../../services/NtfyService.js';
import {UserService} from '../../services/UserService.js';
import {CatchAsync} from '../../utils/asyncHandler.js';
@@ -53,6 +54,7 @@ export class Google {
);
let user = await UserService.email(email);
let isNewUser = false;
if (!user) {
user = await prisma.user.create({
@@ -61,12 +63,18 @@ export class Google {
type: 'GOOGLE_OAUTH',
},
});
isNewUser = true;
}
if (user.type !== 'GOOGLE_OAUTH') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=You used another form of authentication');
}
// Send notification if this is a new user
if (isNewUser) {
await NtfyService.notifyUserOAuthSignup(user.email, user.id, 'Google');
}
const token = jwt.sign(user.id);
const cookie = UserService.cookieOptions();
+22
View File
@@ -11,6 +11,7 @@ import {NotAuthenticated, NotFound} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {isAuthenticated} from '../middleware/auth.js';
import {BillingLimitService} from '../services/BillingLimitService.js';
import {NtfyService} from '../services/NtfyService.js';
import {SecurityService} from '../services/SecurityService.js';
import {UserService} from '../services/UserService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -82,6 +83,9 @@ export class Users {
},
});
// Send notification about project creation
await NtfyService.notifyProjectCreated(project.name, project.id, auth.userId);
return res.status(201).json(project);
}
@@ -150,8 +154,22 @@ export class Users {
public: publicKey,
secret: secretKey,
},
select: {
id: true,
name: true,
public: true,
secret: true,
createdAt: true,
updatedAt: true,
disabled: true,
customer: true,
subscription: true,
},
});
// Send notification about API key regeneration
await NtfyService.notifyApiKeysRegenerated(project.name!, id!, auth.userId!);
return res.status(200).json(project);
}
@@ -717,6 +735,7 @@ export class Users {
const project = await prisma.project.findUnique({
where: {id},
select: {
name: true,
subscription: true,
customer: true,
},
@@ -741,6 +760,9 @@ export class Users {
where: {id},
});
// Send notification about project deletion
await NtfyService.notifyProjectDeleted(project.name, id, auth.userId);
return res.status(200).json({success: true, message: 'Project deleted successfully'});
}
}
+28 -5
View File
@@ -9,6 +9,7 @@ import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {EventService} from '../services/EventService.js';
import {NtfyService} from '../services/NtfyService.js';
import {SecurityService} from '../services/SecurityService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -165,6 +166,14 @@ export class Webhooks {
bounceType: body.bounce?.bounceType,
bouncedAt: now.toISOString(),
};
// Send notification about bounce
await NtfyService.notifyEmailBounce(
email.project.name,
email.projectId,
email.contact.email,
body.bounce?.bounceType,
);
break;
case 'Complaint':
@@ -180,6 +189,9 @@ export class Webhooks {
...baseEventData,
complainedAt: now.toISOString(),
};
// Send notification about complaint
await NtfyService.notifyEmailComplaint(email.project.name, email.projectId, email.contact.email);
break;
default:
@@ -256,7 +268,7 @@ export class Webhooks {
}
// Update project with customer and subscription IDs
await prisma.project.update({
const updatedProject = await prisma.project.update({
where: {id: projectId},
data: {
customer: customerId,
@@ -265,6 +277,9 @@ export class Webhooks {
});
signale.success(`[WEBHOOK] Checkout completed for project ${projectId}`);
// Send notification about subscription started
await NtfyService.notifySubscriptionStarted(updatedProject.name, projectId, subscriptionId);
break;
}
@@ -283,7 +298,9 @@ export class Webhooks {
}
signale.success(`[WEBHOOK] Invoice paid for project ${project.name} (${project.id})`);
// Additional logic can be added here (e.g., extend trial, update billing status)
// Send notification about invoice payment
await NtfyService.notifyInvoicePaid(project.name, project.id);
break;
}
@@ -302,7 +319,9 @@ export class Webhooks {
}
signale.warn(`[WEBHOOK] Payment failed for project ${project.name} (${project.id})`);
// Additional logic can be added here (e.g., disable project, send notification)
// Send notification about payment failure
await NtfyService.notifyPaymentFailed(project.name, project.id);
break;
}
@@ -329,7 +348,9 @@ export class Webhooks {
});
signale.warn(`[WEBHOOK] Subscription deleted for project ${project.name} (${project.id})`);
// Additional logic can be added here (e.g., downgrade to free plan)
// Send notification about subscription cancellation
await NtfyService.notifySubscriptionCancelled(project.name, project.id, subscriptionId);
break;
}
@@ -351,7 +372,9 @@ export class Webhooks {
signale.info(
`[WEBHOOK] Status: ${subscription.status}, Cancel at period end: ${subscription.cancel_at_period_end}`,
);
// Additional logic can be added here (e.g., update subscription status, handle plan changes)
// Send notification about subscription update
await NtfyService.notifySubscriptionUpdated(project.name, project.id);
break;
}
@@ -3,6 +3,7 @@ import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {NtfyService} from './NtfyService.js';
/**
* Usage information for a specific email category
@@ -189,6 +190,17 @@ export class BillingLimitService {
// Check if blocked (at or over limit)
if (usage >= limit) {
// Get project name for notification
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {name: true},
});
if (project) {
// Send notification about limit exceeded
await NtfyService.notifyBillingLimitExceeded(project.name, projectId, usage, limit, sourceType);
}
return {
allowed: false,
warning: false,
@@ -202,6 +214,18 @@ export class BillingLimitService {
// Check if warning (80% or more)
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
// Send notification for warning threshold
if (isWarning) {
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {name: true},
});
if (project) {
await NtfyService.notifyBillingLimitApproaching(project.name, projectId, usage, limit, percentage, sourceType);
}
}
return {
allowed: true,
warning: isWarning,
+80 -5
View File
@@ -8,6 +8,7 @@ import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
import {DomainService} from './DomainService.js';
import {EmailService} from './EmailService.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
import {SegmentService} from './SegmentService.js';
import {DASHBOARD_URI} from '../app/constants.js';
@@ -71,7 +72,7 @@ export class CampaignService {
}
// Create campaign
return prisma.campaign.create({
const campaign = await prisma.campaign.create({
data: {
projectId,
name: data.name,
@@ -86,7 +87,17 @@ export class CampaignService {
segmentId: data.segmentId,
status: CampaignStatus.DRAFT,
},
include: {
project: {
select: {name: true},
},
},
});
// Send notification about campaign creation
await NtfyService.notifyCampaignCreated(campaign.name, campaign.project.name, projectId);
return campaign;
}
/**
@@ -208,7 +219,18 @@ export class CampaignService {
* Delete a campaign
*/
public static async delete(projectId: string, campaignId: string): Promise<void> {
const campaign = await this.get(projectId, campaignId);
const campaign = await prisma.campaign.findUnique({
where: {id: campaignId, projectId},
include: {
project: {
select: {name: true},
},
},
});
if (!campaign) {
throw new HttpException(404, 'Campaign not found');
}
// Can only delete draft campaigns
if (campaign.status !== CampaignStatus.DRAFT) {
@@ -218,6 +240,9 @@ export class CampaignService {
await prisma.campaign.delete({
where: {id: campaignId},
});
// Send notification about campaign deletion
await NtfyService.notifyCampaignDeleted(campaign.name, campaign.project.name, projectId);
}
/**
@@ -283,11 +308,25 @@ export class CampaignService {
scheduledFor,
totalRecipients: recipientCount,
},
include: {
project: {
select: {name: true},
},
},
});
// Queue for scheduled sending
await QueueService.scheduleCampaign(campaignId, scheduledFor);
// Send notification about campaign scheduled
await NtfyService.notifyCampaignScheduled(
updatedCampaign.name,
updatedCampaign.project.name,
projectId,
scheduledFor,
recipientCount,
);
return updatedCampaign;
} else {
// Send immediately - start the batch processing
@@ -319,15 +358,28 @@ export class CampaignService {
}
// Update campaign to SENDING status
await prisma.campaign.update({
const updatedCampaign = await prisma.campaign.update({
where: {id: campaignId},
data: {
status: CampaignStatus.SENDING,
totalRecipients: recipientCount,
sentAt: new Date(),
},
include: {
project: {
select: {name: true},
},
},
});
// Send notification about campaign sending started
await NtfyService.notifyCampaignSendingStarted(
updatedCampaign.name,
updatedCampaign.project.name,
projectId,
recipientCount,
);
// Queue first batch to start the cursor-based chain
await QueueService.queueCampaignBatch({
campaignId,
@@ -432,12 +484,25 @@ export class CampaignService {
});
} else {
// All batches processed, mark campaign as SENT
await prisma.campaign.update({
const completedCampaign = await prisma.campaign.update({
where: {id: campaignId},
data: {
status: CampaignStatus.SENT,
},
include: {
project: {
select: {name: true},
},
},
});
// Send notification about campaign send completed
await NtfyService.notifyCampaignSendCompleted(
completedCampaign.name,
completedCampaign.project.name,
completedCampaign.projectId,
completedCampaign.totalRecipients || 0,
);
}
}
@@ -458,12 +523,22 @@ export class CampaignService {
}
// Update status
return prisma.campaign.update({
const cancelledCampaign = await prisma.campaign.update({
where: {id: campaignId},
data: {
status: CampaignStatus.CANCELLED,
},
include: {
project: {
select: {name: true},
},
},
});
// Send notification about campaign cancellation
await NtfyService.notifyCampaignCancelled(cancelledCampaign.name, cancelledCampaign.project.name, projectId);
return cancelledCampaign;
}
/**
+38 -3
View File
@@ -2,6 +2,7 @@ import {prisma} from '../database/prisma.js';
import {wrapRedis} from '../database/redis.js';
import {HttpException} from '../exceptions/index.js';
import {Keys} from './keys.js';
import {NtfyService} from './NtfyService.js';
import {getDomainVerificationAttributes, verifyDomain} from './SESService.js';
export class DomainService {
@@ -41,8 +42,16 @@ export class DomainService {
verified: false,
dkimTokens,
},
include: {
project: {
select: {name: true},
},
},
});
// Send notification about domain added
await NtfyService.notifyDomainAdded(domain, newDomain.project.name, projectId);
return newDomain;
}
@@ -60,15 +69,31 @@ export class DomainService {
// Update domain if verification status changed
if (attributes.status === 'Success' && !domain.verified) {
await prisma.domain.update({
const updatedDomain = await prisma.domain.update({
where: {id: domainId},
data: {verified: true},
include: {
project: {
select: {name: true, id: true},
},
},
});
// Send notification about domain verified
await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
} else if (attributes.status !== 'Success' && domain.verified) {
await prisma.domain.update({
const updatedDomain = await prisma.domain.update({
where: {id: domainId},
data: {verified: false},
include: {
project: {
select: {name: true, id: true},
},
},
});
// Send notification about domain verification failed
await NtfyService.notifyDomainVerificationFailed(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
}
return {
@@ -83,7 +108,14 @@ export class DomainService {
* Remove a domain from a project
*/
public static async removeDomain(domainId: string) {
const domain = await prisma.domain.findUnique({where: {id: domainId}});
const domain = await prisma.domain.findUnique({
where: {id: domainId},
include: {
project: {
select: {name: true, id: true},
},
},
});
if (!domain) {
throw new Error('Domain not found');
@@ -152,6 +184,9 @@ export class DomainService {
await prisma.domain.delete({where: {id: domainId}});
// Send notification about domain removal
await NtfyService.notifyDomainRemoved(domainName, domain.project.name, domain.project.id);
return true;
}
+727
View File
@@ -0,0 +1,727 @@
import signale from 'signale';
/**
* Priority levels for ntfy notifications
* Based on ntfy.sh documentation
*/
export enum NtfyPriority {
MIN = 1, // No vibration/sound, relegated to "Other notifications"
LOW = 2, // No vibration/sound, hidden until drawer opened
DEFAULT = 3, // Short vibration and sound (standard)
HIGH = 4, // Long vibration, pop-over notification
MAX = 5, // Long vibration bursts, pop-over notification
}
/**
* Tags for ntfy notifications (emoji shortcuts)
*/
export enum NtfyTag {
WARNING = 'warning',
ERROR = 'rotating_light',
SUCCESS = 'white_check_mark',
MONEY = 'money_with_wings',
SHIELD = 'shield',
ROCKET = 'rocket',
BELL = 'bell',
CHART = 'chart_with_upwards_trend',
SKULL = 'skull',
INFO = 'information_source',
}
export interface NtfyNotification {
title: string;
message: string;
priority?: NtfyPriority;
tags?: NtfyTag[];
}
/**
* Service for sending notifications via ntfy.sh
* Supports configurable ntfy server URL via NTFY_URL environment variable
*/
export class NtfyService {
private static ntfyUrl: string | null = null;
private static isConfigured = false;
/**
* Initialize the ntfy service with the configured URL
*/
private static initialize(): void {
if (this.isConfigured) {
return;
}
this.ntfyUrl = process.env.NTFY_URL || null;
this.isConfigured = true;
if (!this.ntfyUrl) {
signale.warn('[NTFY] NTFY_URL not configured - notifications will be skipped');
} else {
signale.info(`[NTFY] Initialized with URL: ${this.ntfyUrl}`);
}
}
/**
* Send a notification to ntfy
* @param notification - The notification details
* @returns Promise<void>
*/
public static async send(notification: NtfyNotification): Promise<void> {
this.initialize();
if (!this.ntfyUrl) {
// Silently skip if not configured
return;
}
try {
const headers: Record<string, string> = {
'Content-Type': 'text/plain',
Title: notification.title,
};
if (notification.priority) {
headers.Priority = notification.priority.toString();
}
if (notification.tags && notification.tags.length > 0) {
headers.Tags = notification.tags.join(',');
}
const response = await fetch(this.ntfyUrl, {
method: 'POST',
headers,
body: notification.message,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
signale.success(`[NTFY] Sent notification: ${notification.title}`);
} catch (error) {
signale.error('[NTFY] Failed to send notification:', error);
// Don't throw - notifications should not break the main flow
}
}
/**
* Send a high-priority notification
*/
public static async sendHigh(title: string, message: string, tags?: NtfyTag[]): Promise<void> {
await this.send({
title,
message,
priority: NtfyPriority.HIGH,
tags,
});
}
/**
* Send a max-priority urgent notification
*/
public static async sendUrgent(title: string, message: string, tags?: NtfyTag[]): Promise<void> {
await this.send({
title,
message,
priority: NtfyPriority.MAX,
tags,
});
}
/**
* Send a low-priority informational notification
*/
public static async sendInfo(title: string, message: string, tags?: NtfyTag[]): Promise<void> {
await this.send({
title,
message,
priority: NtfyPriority.LOW,
tags,
});
}
/**
* Send a default priority notification
*/
public static async sendDefault(title: string, message: string, tags?: NtfyTag[]): Promise<void> {
await this.send({
title,
message,
priority: NtfyPriority.DEFAULT,
tags,
});
}
// ===== Event-specific notification helpers =====
/**
* Notify about a new project creation
*/
public static async notifyProjectCreated(projectName: string, projectId: string, userId: string): Promise<void> {
await this.sendDefault(
'New Project Created',
`Project "${projectName}" (${projectId}) was created by user ${userId}`,
[NtfyTag.ROCKET, NtfyTag.SUCCESS],
);
}
/**
* Notify about subscription started
*/
public static async notifySubscriptionStarted(
projectName: string,
projectId: string,
subscriptionId: string,
): Promise<void> {
await this.sendHigh(
'Subscription Started',
`Project "${projectName}" (${projectId}) started a paid subscription (${subscriptionId})`,
[NtfyTag.MONEY, NtfyTag.SUCCESS],
);
}
/**
* Notify about subscription cancelled
*/
public static async notifySubscriptionCancelled(
projectName: string,
projectId: string,
subscriptionId: string,
): Promise<void> {
await this.sendHigh(
'Subscription Cancelled',
`Project "${projectName}" (${projectId}) cancelled their subscription (${subscriptionId})`,
[NtfyTag.WARNING, NtfyTag.MONEY],
);
}
/**
* Notify about project disabled due to security risk
*/
public static async notifyProjectDisabledForSecurity(
projectName: string,
projectId: string,
violations: string[],
): Promise<void> {
const violationText = violations.join(', ');
await this.sendUrgent(
'Project Disabled - Security Risk',
`Project "${projectName}" (${projectId}) was automatically disabled due to: ${violationText}`,
[NtfyTag.SHIELD, NtfyTag.ERROR, NtfyTag.SKULL],
);
}
/**
* Notify about payment failure
*/
public static async notifyPaymentFailed(projectName: string, projectId: string): Promise<void> {
await this.sendHigh(
'Payment Failed',
`Payment failed for project "${projectName}" (${projectId})`,
[NtfyTag.WARNING, NtfyTag.MONEY],
);
}
/**
* Notify about successful invoice payment - MIN priority (routine)
*/
public static async notifyInvoicePaid(projectName: string, projectId: string): Promise<void> {
await this.send({
title: 'Invoice Paid',
message: `Invoice paid for project "${projectName}" (${projectId})`,
priority: NtfyPriority.MIN,
tags: [NtfyTag.MONEY, NtfyTag.SUCCESS],
});
}
/**
* Notify about subscription update - LOW priority (minor changes)
*/
public static async notifySubscriptionUpdated(projectName: string, projectId: string): Promise<void> {
await this.send({
title: 'Subscription Updated',
message: `Subscription updated for project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.MONEY, NtfyTag.INFO],
});
}
/**
* Notify about project deletion - DEFAULT priority
*/
public static async notifyProjectDeleted(projectName: string, projectId: string, userId: string): Promise<void> {
await this.sendDefault(
'Project Deleted',
`Project "${projectName}" (${projectId}) was deleted by user ${userId}`,
[NtfyTag.WARNING],
);
}
/**
* Notify about security warning (non-critical)
*/
public static async notifySecurityWarning(
projectName: string,
projectId: string,
warnings: string[],
): Promise<void> {
const warningText = warnings.join(', ');
await this.sendDefault(
'Security Warning',
`Project "${projectName}" (${projectId}) has security warnings: ${warningText}`,
[NtfyTag.WARNING, NtfyTag.SHIELD],
);
}
/**
* Notify about email bounce - LOW priority (high volume)
*/
public static async notifyEmailBounce(
projectName: string,
projectId: string,
recipientEmail: string,
bounceType?: string,
): Promise<void> {
const bounceInfo = bounceType ? ` (${bounceType})` : '';
await this.send({
title: 'Email Bounced',
message: `Email to ${recipientEmail} bounced${bounceInfo} in project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.WARNING],
});
}
/**
* Notify about email complaint - HIGH priority (reputation risk)
*/
public static async notifyEmailComplaint(
projectName: string,
projectId: string,
recipientEmail: string,
): Promise<void> {
await this.sendHigh(
'Email Complaint',
`Email complaint received from ${recipientEmail} in project "${projectName}" (${projectId})`,
[NtfyTag.WARNING, NtfyTag.ERROR],
);
}
/**
* Notify about new user account created via signup - LOW priority
*/
public static async notifyUserSignup(userEmail: string, userId: string): Promise<void> {
await this.send({
title: 'New User Signup',
message: `New user registered: ${userEmail} (${userId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.ROCKET, NtfyTag.SUCCESS],
});
}
/**
* Notify about new user account created via OAuth - LOW priority
*/
public static async notifyUserOAuthSignup(
userEmail: string,
userId: string,
provider: 'GitHub' | 'Google',
): Promise<void> {
await this.send({
title: `New User - ${provider} OAuth`,
message: `New user registered via ${provider}: ${userEmail} (${userId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.ROCKET, NtfyTag.SUCCESS],
});
}
// ===== Campaign event notifications =====
/**
* Notify about campaign created (draft) - LOW priority
*/
public static async notifyCampaignCreated(
campaignName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.send({
title: 'Campaign Created',
message: `Campaign "${campaignName}" created in project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.ROCKET],
});
}
/**
* Notify about campaign scheduled - DEFAULT priority
*/
public static async notifyCampaignScheduled(
campaignName: string,
projectName: string,
projectId: string,
scheduledFor: Date,
recipientCount: number,
): Promise<void> {
await this.sendDefault(
'Campaign Scheduled',
`Campaign "${campaignName}" scheduled to send to ${recipientCount} recipients at ${scheduledFor.toISOString()} in project "${projectName}" (${projectId})`,
[NtfyTag.ROCKET, NtfyTag.INFO],
);
}
/**
* Notify about campaign sending started - LOW priority (informational)
*/
public static async notifyCampaignSendingStarted(
campaignName: string,
projectName: string,
projectId: string,
recipientCount: number,
): Promise<void> {
await this.send({
title: 'Campaign Sending Started',
message: `Campaign "${campaignName}" started sending to ${recipientCount} recipients in project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.ROCKET, NtfyTag.CHART],
});
}
/**
* Notify about campaign send completed - DEFAULT priority
*/
public static async notifyCampaignSendCompleted(
campaignName: string,
projectName: string,
projectId: string,
recipientCount: number,
): Promise<void> {
await this.sendDefault(
'Campaign Send Completed',
`Campaign "${campaignName}" successfully sent to ${recipientCount} recipients in project "${projectName}" (${projectId})`,
[NtfyTag.ROCKET, NtfyTag.SUCCESS],
);
}
/**
* Notify about campaign cancelled - LOW priority
*/
public static async notifyCampaignCancelled(
campaignName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.send({
title: 'Campaign Cancelled',
message: `Campaign "${campaignName}" was cancelled in project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.WARNING],
});
}
/**
* Notify about campaign deleted - LOW priority
*/
public static async notifyCampaignDeleted(
campaignName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.send({
title: 'Campaign Deleted',
message: `Campaign "${campaignName}" was deleted from project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.INFO],
});
}
// ===== Workflow event notifications =====
/**
* Notify about workflow created - LOW priority
*/
public static async notifyWorkflowCreated(
workflowName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.send({
title: 'Workflow Created',
message: `Workflow "${workflowName}" created in project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.ROCKET],
});
}
/**
* Notify about workflow enabled
*/
public static async notifyWorkflowEnabled(
workflowName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.sendDefault(
'Workflow Enabled',
`Workflow "${workflowName}" is now active in project "${projectName}" (${projectId})`,
[NtfyTag.SUCCESS],
);
}
/**
* Notify about workflow disabled
*/
public static async notifyWorkflowDisabled(
workflowName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.sendDefault(
'Workflow Disabled',
`Workflow "${workflowName}" has been disabled in project "${projectName}" (${projectId})`,
[NtfyTag.WARNING],
);
}
/**
* Notify about workflow deleted - LOW priority
*/
public static async notifyWorkflowDeleted(
workflowName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.send({
title: 'Workflow Deleted',
message: `Workflow "${workflowName}" was deleted from project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.INFO],
});
}
/**
* Notify about workflow execution failed
*/
public static async notifyWorkflowExecutionFailed(
workflowName: string,
projectName: string,
projectId: string,
contactEmail: string,
error: string,
): Promise<void> {
await this.sendHigh(
'Workflow Execution Failed',
`Workflow "${workflowName}" failed for contact ${contactEmail} in project "${projectName}" (${projectId}). Error: ${error}`,
[NtfyTag.ERROR, NtfyTag.WARNING],
);
}
// ===== Domain verification notifications =====
/**
* Notify about domain added
*/
public static async notifyDomainAdded(domain: string, projectName: string, projectId: string): Promise<void> {
await this.sendDefault(
'Domain Added',
`Domain "${domain}" added for verification in project "${projectName}" (${projectId})`,
[NtfyTag.INFO],
);
}
/**
* Notify about domain verified
*/
public static async notifyDomainVerified(domain: string, projectName: string, projectId: string): Promise<void> {
await this.sendDefault(
'Domain Verified',
`Domain "${domain}" is now verified and ready for use in project "${projectName}" (${projectId})`,
[NtfyTag.SUCCESS, NtfyTag.SHIELD],
);
}
/**
* Notify about domain verification failed
*/
public static async notifyDomainVerificationFailed(
domain: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.sendHigh(
'Domain Verification Failed',
`Domain "${domain}" failed verification in project "${projectName}" (${projectId}). Email sending may be affected.`,
[NtfyTag.WARNING, NtfyTag.SHIELD],
);
}
/**
* Notify about domain removed
*/
public static async notifyDomainRemoved(domain: string, projectName: string, projectId: string): Promise<void> {
await this.sendInfo(
'Domain Removed',
`Domain "${domain}" was removed from project "${projectName}" (${projectId})`,
[NtfyTag.INFO],
);
}
// ===== Billing and usage limit notifications =====
/**
* Notify about billing limit approaching (80% threshold)
*/
public static async notifyBillingLimitApproaching(
projectName: string,
projectId: string,
usage: number,
limit: number,
percentage: number,
sourceType: string,
): Promise<void> {
await this.sendDefault(
'Billing Limit Warning',
`Email usage at ${Math.round(percentage)}% (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId})`,
[NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.CHART],
);
}
/**
* Notify about billing limit exceeded - MAX priority (blocks operations)
*/
public static async notifyBillingLimitExceeded(
projectName: string,
projectId: string,
usage: number,
limit: number,
sourceType: string,
): Promise<void> {
await this.sendUrgent(
'Billing Limit Exceeded',
`Email usage limit reached (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId}). Further emails are blocked.`,
[NtfyTag.ERROR, NtfyTag.MONEY, NtfyTag.SKULL],
);
}
// ===== API key notifications =====
/**
* Notify about API keys regenerated
*/
public static async notifyApiKeysRegenerated(
projectName: string,
projectId: string,
userId: string,
): Promise<void> {
await this.sendHigh(
'API Keys Regenerated',
`API keys for project "${projectName}" (${projectId}) were regenerated by user ${userId}`,
[NtfyTag.SHIELD, NtfyTag.WARNING],
);
}
// ===== Contact import notifications =====
/**
* Notify about contact import started - MIN priority (routine, high volume)
*/
public static async notifyContactImportStarted(
projectName: string,
projectId: string,
filename: string,
totalRows: number,
): Promise<void> {
await this.send({
title: 'Contact Import Started',
message: `Importing ${totalRows} contacts from "${filename}" into project "${projectName}" (${projectId})`,
priority: NtfyPriority.MIN,
tags: [NtfyTag.ROCKET],
});
}
/**
* Notify about contact import completed
*/
public static async notifyContactImportCompleted(
projectName: string,
projectId: string,
filename: string,
successCount: number,
createdCount: number,
updatedCount: number,
failureCount: number,
): Promise<void> {
await this.sendDefault(
'Contact Import Completed',
`Import "${filename}" completed for project "${projectName}" (${projectId}). Success: ${successCount} (${createdCount} new, ${updatedCount} updated), Errors: ${failureCount}`,
[NtfyTag.SUCCESS, NtfyTag.CHART],
);
}
/**
* Notify about contact import failed
*/
public static async notifyContactImportFailed(
projectName: string,
projectId: string,
filename: string,
error: string,
): Promise<void> {
await this.sendHigh(
'Contact Import Failed',
`Contact import "${filename}" failed for project "${projectName}" (${projectId}). Error: ${error}`,
[NtfyTag.ERROR],
);
}
// ===== Segment notifications =====
/**
* Notify about segment created - MIN priority
*/
public static async notifySegmentCreated(
segmentName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.send({
title: 'Segment Created',
message: `Segment "${segmentName}" created in project "${projectName}" (${projectId})`,
priority: NtfyPriority.MIN,
tags: [NtfyTag.INFO],
});
}
/**
* Notify about segment membership computed - LOW priority
*/
public static async notifySegmentMembershipComputed(
segmentName: string,
projectName: string,
projectId: string,
memberCount: number,
): Promise<void> {
await this.send({
title: 'Segment Membership Updated',
message: `Segment "${segmentName}" in project "${projectName}" (${projectId}) now has ${memberCount} members`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.CHART],
});
}
/**
* Notify about segment deleted - MIN priority
*/
public static async notifySegmentDeleted(
segmentName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.send({
title: 'Segment Deleted',
message: `Segment "${segmentName}" was deleted from project "${projectName}" (${projectId})`,
priority: NtfyPriority.MIN,
tags: [NtfyTag.INFO],
});
}
}
+14 -2
View File
@@ -2,6 +2,7 @@ import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
/**
@@ -112,6 +113,17 @@ export class SecurityService {
} else if (status.warnings.length > 0) {
// Log warnings for monitoring
signale.warn(`[SECURITY] Project ${projectId} has security warnings:`, status.warnings);
// Get project name for notification
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {name: true},
});
if (project) {
// Send notification about security warning
await NtfyService.notifySecurityWarning(project.name, projectId, status.warnings);
}
}
} catch (error) {
// Log error but don't throw - we don't want security checks to break the webhook
@@ -331,8 +343,8 @@ export class SecurityService {
signale.error(`[SECURITY] Failed to cancel pending jobs for project ${projectId}:`, error);
}
// TODO: Send notification to project owners about the suspension
// This could be implemented using the notification service or email alert
// Send urgent notification about project suspension
await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, status.violations);
} catch (error) {
signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error);
}
@@ -16,6 +16,7 @@ import {HttpException} from '../exceptions/index.js';
import {DASHBOARD_URI} from '../app/constants.js';
import {EmailService} from './EmailService.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
// Type aliases for workflow execution context
@@ -259,14 +260,36 @@ export class WorkflowExecutionService {
});
// Mark workflow execution as failed
await prisma.workflowExecution.update({
const failedExecution = await prisma.workflowExecution.update({
where: {id: executionId},
data: {
status: WorkflowExecutionStatus.FAILED,
completedAt: new Date(),
},
include: {
workflow: {
select: {
name: true,
project: {
select: {name: true, id: true},
},
},
},
contact: {
select: {email: true},
},
},
});
// Send notification about workflow execution failure
await NtfyService.notifyWorkflowExecutionFailed(
failedExecution.workflow.name,
failedExecution.workflow.project.name,
failedExecution.workflow.project.id,
failedExecution.contact.email,
error instanceof Error ? error.message : 'Unknown error',
);
throw error;
}
}
+43 -3
View File
@@ -4,8 +4,9 @@ import {Prisma, WorkflowExecutionStatus} from '@plunk/db';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {EventService} from './EventService.js';
import {ContactService} from './ContactService.js';
import {EventService} from './EventService.js';
import {NtfyService} from './NtfyService.js';
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
export interface PaginatedWorkflows {
@@ -171,6 +172,17 @@ export class WorkflowService {
await EventService.invalidateWorkflowCache(projectId);
}
// Get project name for notification
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {name: true},
});
if (project) {
// Send notification about workflow creation
await NtfyService.notifyWorkflowCreated(workflow.name, project.name, projectId);
}
return workflow;
}
@@ -225,6 +237,11 @@ export class WorkflowService {
const updated = await prisma.workflow.update({
where: {id: workflowId},
data: updateData,
include: {
project: {
select: {name: true},
},
},
});
// Invalidate workflow cache if enabled status changed or workflow is enabled
@@ -232,6 +249,15 @@ export class WorkflowService {
await EventService.invalidateWorkflowCache(projectId);
}
// Send notification if enabled status changed
if (data.enabled !== undefined && data.enabled !== workflow.enabled) {
if (data.enabled) {
await NtfyService.notifyWorkflowEnabled(updated.name, updated.project.name, projectId);
} else {
await NtfyService.notifyWorkflowDisabled(updated.name, updated.project.name, projectId);
}
}
return updated;
}
@@ -239,8 +265,19 @@ export class WorkflowService {
* Delete a workflow
*/
public static async delete(projectId: string, workflowId: string): Promise<void> {
// Verify workflow exists and belongs to project
const workflow = await this.get(projectId, workflowId);
// Verify workflow exists and belongs to project - get with project name
const workflow = await prisma.workflow.findUnique({
where: {id: workflowId, projectId},
include: {
project: {
select: {name: true},
},
},
});
if (!workflow) {
throw new HttpException(404, 'Workflow not found');
}
// Check if workflow has active executions
const activeExecutions = await this.hasActiveExecutions(workflowId);
@@ -260,6 +297,9 @@ export class WorkflowService {
if (workflow.enabled) {
await EventService.invalidateWorkflowCache(projectId);
}
// Send notification about workflow deletion
await NtfyService.notifyWorkflowDeleted(workflow.name, workflow.project.name, projectId);
}
/**