Add ntfy.sh
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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'});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user