Add ntfy.sh
This commit is contained in:
@@ -75,6 +75,24 @@ S3_BUCKET=uploads
|
|||||||
S3_PUBLIC_URL=http://localhost:9000/uploads
|
S3_PUBLIC_URL=http://localhost:9000/uploads
|
||||||
S3_FORCE_PATH_STYLE=true
|
S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# OPTIONAL: Notifications (ntfy.sh)
|
||||||
|
# ========================================
|
||||||
|
# Plunk includes a self-hosted ntfy.sh server for system notifications
|
||||||
|
# You can access the ntfy web UI at http://localhost:8080 (or your configured NTFY_PORT)
|
||||||
|
# Subscribe to the topic 'plunk-notifications' to receive notifications
|
||||||
|
|
||||||
|
# Port for ntfy web UI and API (default: 8080)
|
||||||
|
NTFY_PORT=8080
|
||||||
|
|
||||||
|
# Ntfy topic URL - Uses internal ntfy service by default
|
||||||
|
# To use external ntfy.sh or your own server, change this URL
|
||||||
|
# Examples:
|
||||||
|
# - Self-hosted (default): http://ntfy/plunk-notifications
|
||||||
|
# - Public ntfy.sh: https://ntfy.sh/your-unique-topic-name
|
||||||
|
# - Custom server: https://your-ntfy-server.com/your-topic
|
||||||
|
NTFY_URL=http://ntfy/plunk-notifications
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# OPTIONAL: SMTP Server
|
# OPTIONAL: SMTP Server
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ Required for builds and deployment (see turbo.json and .env.example):
|
|||||||
- OAuth (optional): `GITHUB_OAUTH_CLIENT`, `GITHUB_OAUTH_SECRET`, `GOOGLE_OAUTH_CLIENT`, `GOOGLE_OAUTH_SECRET`
|
- OAuth (optional): `GITHUB_OAUTH_CLIENT`, `GITHUB_OAUTH_SECRET`, `GOOGLE_OAUTH_CLIENT`, `GOOGLE_OAUTH_SECRET`
|
||||||
- Stripe (optional): `STRIPE_SK`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ONBOARDING`, `STRIPE_PRICE_EMAIL_USAGE`,
|
- Stripe (optional): `STRIPE_SK`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ONBOARDING`, `STRIPE_PRICE_EMAIL_USAGE`,
|
||||||
`STRIPE_METER_EVENT_NAME`
|
`STRIPE_METER_EVENT_NAME`
|
||||||
|
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
|
||||||
|
|
||||||
**Important Notes:**
|
**Important Notes:**
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {prisma} from '../database/prisma.js';
|
|||||||
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
|
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
|
||||||
import {jwt} from '../middleware/auth.js';
|
import {jwt} from '../middleware/auth.js';
|
||||||
import {AuthService} from '../services/AuthService.js';
|
import {AuthService} from '../services/AuthService.js';
|
||||||
|
import {NtfyService} from '../services/NtfyService.js';
|
||||||
import {UserService} from '../services/UserService.js';
|
import {UserService} from '../services/UserService.js';
|
||||||
import {Keys} from '../services/keys.js';
|
import {Keys} from '../services/keys.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.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);
|
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 token = jwt.sign(created_user.id);
|
||||||
const cookie = UserService.cookieOptions();
|
const cookie = UserService.cookieOptions();
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '../../app/constants.js';
|
} from '../../app/constants.js';
|
||||||
import {prisma} from '../../database/prisma.js';
|
import {prisma} from '../../database/prisma.js';
|
||||||
import {jwt} from '../../middleware/auth.js';
|
import {jwt} from '../../middleware/auth.js';
|
||||||
|
import {NtfyService} from '../../services/NtfyService.js';
|
||||||
import {UserService} from '../../services/UserService.js';
|
import {UserService} from '../../services/UserService.js';
|
||||||
import {CatchAsync} from '../../utils/asyncHandler.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;
|
const email = emails.find((e: {primary: boolean; email: string}) => e.primary).email;
|
||||||
|
|
||||||
let user = await UserService.email(email as string);
|
let user = await UserService.email(email as string);
|
||||||
|
let isNewUser = false;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
user = await prisma.user.create({
|
user = await prisma.user.create({
|
||||||
@@ -67,12 +69,18 @@ export class Github {
|
|||||||
type: 'GITHUB_OAUTH',
|
type: 'GITHUB_OAUTH',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
isNewUser = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.type !== 'GITHUB_OAUTH') {
|
if (user.type !== 'GITHUB_OAUTH') {
|
||||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=You used another form of authentication');
|
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 token = jwt.sign(user.id);
|
||||||
const cookie = UserService.cookieOptions();
|
const cookie = UserService.cookieOptions();
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '../../app/constants.js';
|
} from '../../app/constants.js';
|
||||||
import {prisma} from '../../database/prisma.js';
|
import {prisma} from '../../database/prisma.js';
|
||||||
import {jwt} from '../../middleware/auth.js';
|
import {jwt} from '../../middleware/auth.js';
|
||||||
|
import {NtfyService} from '../../services/NtfyService.js';
|
||||||
import {UserService} from '../../services/UserService.js';
|
import {UserService} from '../../services/UserService.js';
|
||||||
import {CatchAsync} from '../../utils/asyncHandler.js';
|
import {CatchAsync} from '../../utils/asyncHandler.js';
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ export class Google {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let user = await UserService.email(email);
|
let user = await UserService.email(email);
|
||||||
|
let isNewUser = false;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
user = await prisma.user.create({
|
user = await prisma.user.create({
|
||||||
@@ -61,12 +63,18 @@ export class Google {
|
|||||||
type: 'GOOGLE_OAUTH',
|
type: 'GOOGLE_OAUTH',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
isNewUser = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.type !== 'GOOGLE_OAUTH') {
|
if (user.type !== 'GOOGLE_OAUTH') {
|
||||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=You used another form of authentication');
|
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 token = jwt.sign(user.id);
|
||||||
const cookie = UserService.cookieOptions();
|
const cookie = UserService.cookieOptions();
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {NotAuthenticated, NotFound} from '../exceptions/index.js';
|
|||||||
import type {AuthResponse} from '../middleware/auth.js';
|
import type {AuthResponse} from '../middleware/auth.js';
|
||||||
import {isAuthenticated} from '../middleware/auth.js';
|
import {isAuthenticated} from '../middleware/auth.js';
|
||||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||||
|
import {NtfyService} from '../services/NtfyService.js';
|
||||||
import {SecurityService} from '../services/SecurityService.js';
|
import {SecurityService} from '../services/SecurityService.js';
|
||||||
import {UserService} from '../services/UserService.js';
|
import {UserService} from '../services/UserService.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.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);
|
return res.status(201).json(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +154,22 @@ export class Users {
|
|||||||
public: publicKey,
|
public: publicKey,
|
||||||
secret: secretKey,
|
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);
|
return res.status(200).json(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,6 +735,7 @@ export class Users {
|
|||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
where: {id},
|
where: {id},
|
||||||
select: {
|
select: {
|
||||||
|
name: true,
|
||||||
subscription: true,
|
subscription: true,
|
||||||
customer: true,
|
customer: true,
|
||||||
},
|
},
|
||||||
@@ -741,6 +760,9 @@ export class Users {
|
|||||||
where: {id},
|
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'});
|
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 {stripe} from '../app/stripe.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {EventService} from '../services/EventService.js';
|
import {EventService} from '../services/EventService.js';
|
||||||
|
import {NtfyService} from '../services/NtfyService.js';
|
||||||
import {SecurityService} from '../services/SecurityService.js';
|
import {SecurityService} from '../services/SecurityService.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
@@ -165,6 +166,14 @@ export class Webhooks {
|
|||||||
bounceType: body.bounce?.bounceType,
|
bounceType: body.bounce?.bounceType,
|
||||||
bouncedAt: now.toISOString(),
|
bouncedAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Send notification about bounce
|
||||||
|
await NtfyService.notifyEmailBounce(
|
||||||
|
email.project.name,
|
||||||
|
email.projectId,
|
||||||
|
email.contact.email,
|
||||||
|
body.bounce?.bounceType,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'Complaint':
|
case 'Complaint':
|
||||||
@@ -180,6 +189,9 @@ export class Webhooks {
|
|||||||
...baseEventData,
|
...baseEventData,
|
||||||
complainedAt: now.toISOString(),
|
complainedAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Send notification about complaint
|
||||||
|
await NtfyService.notifyEmailComplaint(email.project.name, email.projectId, email.contact.email);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -256,7 +268,7 @@ export class Webhooks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update project with customer and subscription IDs
|
// Update project with customer and subscription IDs
|
||||||
await prisma.project.update({
|
const updatedProject = await prisma.project.update({
|
||||||
where: {id: projectId},
|
where: {id: projectId},
|
||||||
data: {
|
data: {
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
@@ -265,6 +277,9 @@ export class Webhooks {
|
|||||||
});
|
});
|
||||||
|
|
||||||
signale.success(`[WEBHOOK] Checkout completed for project ${projectId}`);
|
signale.success(`[WEBHOOK] Checkout completed for project ${projectId}`);
|
||||||
|
|
||||||
|
// Send notification about subscription started
|
||||||
|
await NtfyService.notifySubscriptionStarted(updatedProject.name, projectId, subscriptionId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +298,9 @@ export class Webhooks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
signale.success(`[WEBHOOK] Invoice paid for project ${project.name} (${project.id})`);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +319,9 @@ export class Webhooks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
signale.warn(`[WEBHOOK] Payment failed for project ${project.name} (${project.id})`);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +348,9 @@ export class Webhooks {
|
|||||||
});
|
});
|
||||||
|
|
||||||
signale.warn(`[WEBHOOK] Subscription deleted for project ${project.name} (${project.id})`);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +372,9 @@ export class Webhooks {
|
|||||||
signale.info(
|
signale.info(
|
||||||
`[WEBHOOK] Status: ${subscription.status}, Cancel at period end: ${subscription.cancel_at_period_end}`,
|
`[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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import signale from 'signale';
|
|||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
|
import {NtfyService} from './NtfyService.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Usage information for a specific email category
|
* Usage information for a specific email category
|
||||||
@@ -189,6 +190,17 @@ export class BillingLimitService {
|
|||||||
|
|
||||||
// Check if blocked (at or over limit)
|
// Check if blocked (at or over limit)
|
||||||
if (usage >= 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 {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
warning: false,
|
warning: false,
|
||||||
@@ -202,6 +214,18 @@ export class BillingLimitService {
|
|||||||
// Check if warning (80% or more)
|
// Check if warning (80% or more)
|
||||||
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
|
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 {
|
return {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
warning: isWarning,
|
warning: isWarning,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
|
|||||||
|
|
||||||
import {DomainService} from './DomainService.js';
|
import {DomainService} from './DomainService.js';
|
||||||
import {EmailService} from './EmailService.js';
|
import {EmailService} from './EmailService.js';
|
||||||
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {QueueService} from './QueueService.js';
|
import {QueueService} from './QueueService.js';
|
||||||
import {SegmentService} from './SegmentService.js';
|
import {SegmentService} from './SegmentService.js';
|
||||||
import {DASHBOARD_URI} from '../app/constants.js';
|
import {DASHBOARD_URI} from '../app/constants.js';
|
||||||
@@ -71,7 +72,7 @@ export class CampaignService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create campaign
|
// Create campaign
|
||||||
return prisma.campaign.create({
|
const campaign = await prisma.campaign.create({
|
||||||
data: {
|
data: {
|
||||||
projectId,
|
projectId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -86,7 +87,17 @@ export class CampaignService {
|
|||||||
segmentId: data.segmentId,
|
segmentId: data.segmentId,
|
||||||
status: CampaignStatus.DRAFT,
|
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
|
* Delete a campaign
|
||||||
*/
|
*/
|
||||||
public static async delete(projectId: string, campaignId: string): Promise<void> {
|
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
|
// Can only delete draft campaigns
|
||||||
if (campaign.status !== CampaignStatus.DRAFT) {
|
if (campaign.status !== CampaignStatus.DRAFT) {
|
||||||
@@ -218,6 +240,9 @@ export class CampaignService {
|
|||||||
await prisma.campaign.delete({
|
await prisma.campaign.delete({
|
||||||
where: {id: campaignId},
|
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,
|
scheduledFor,
|
||||||
totalRecipients: recipientCount,
|
totalRecipients: recipientCount,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: {name: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Queue for scheduled sending
|
// Queue for scheduled sending
|
||||||
await QueueService.scheduleCampaign(campaignId, scheduledFor);
|
await QueueService.scheduleCampaign(campaignId, scheduledFor);
|
||||||
|
|
||||||
|
// Send notification about campaign scheduled
|
||||||
|
await NtfyService.notifyCampaignScheduled(
|
||||||
|
updatedCampaign.name,
|
||||||
|
updatedCampaign.project.name,
|
||||||
|
projectId,
|
||||||
|
scheduledFor,
|
||||||
|
recipientCount,
|
||||||
|
);
|
||||||
|
|
||||||
return updatedCampaign;
|
return updatedCampaign;
|
||||||
} else {
|
} else {
|
||||||
// Send immediately - start the batch processing
|
// Send immediately - start the batch processing
|
||||||
@@ -319,15 +358,28 @@ export class CampaignService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update campaign to SENDING status
|
// Update campaign to SENDING status
|
||||||
await prisma.campaign.update({
|
const updatedCampaign = await prisma.campaign.update({
|
||||||
where: {id: campaignId},
|
where: {id: campaignId},
|
||||||
data: {
|
data: {
|
||||||
status: CampaignStatus.SENDING,
|
status: CampaignStatus.SENDING,
|
||||||
totalRecipients: recipientCount,
|
totalRecipients: recipientCount,
|
||||||
sentAt: new Date(),
|
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
|
// Queue first batch to start the cursor-based chain
|
||||||
await QueueService.queueCampaignBatch({
|
await QueueService.queueCampaignBatch({
|
||||||
campaignId,
|
campaignId,
|
||||||
@@ -432,12 +484,25 @@ export class CampaignService {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// All batches processed, mark campaign as SENT
|
// All batches processed, mark campaign as SENT
|
||||||
await prisma.campaign.update({
|
const completedCampaign = await prisma.campaign.update({
|
||||||
where: {id: campaignId},
|
where: {id: campaignId},
|
||||||
data: {
|
data: {
|
||||||
status: CampaignStatus.SENT,
|
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
|
// Update status
|
||||||
return prisma.campaign.update({
|
const cancelledCampaign = await prisma.campaign.update({
|
||||||
where: {id: campaignId},
|
where: {id: campaignId},
|
||||||
data: {
|
data: {
|
||||||
status: CampaignStatus.CANCELLED,
|
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 {wrapRedis} from '../database/redis.js';
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
import {Keys} from './keys.js';
|
import {Keys} from './keys.js';
|
||||||
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {getDomainVerificationAttributes, verifyDomain} from './SESService.js';
|
import {getDomainVerificationAttributes, verifyDomain} from './SESService.js';
|
||||||
|
|
||||||
export class DomainService {
|
export class DomainService {
|
||||||
@@ -41,8 +42,16 @@ export class DomainService {
|
|||||||
verified: false,
|
verified: false,
|
||||||
dkimTokens,
|
dkimTokens,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: {name: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send notification about domain added
|
||||||
|
await NtfyService.notifyDomainAdded(domain, newDomain.project.name, projectId);
|
||||||
|
|
||||||
return newDomain;
|
return newDomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,15 +69,31 @@ export class DomainService {
|
|||||||
|
|
||||||
// Update domain if verification status changed
|
// Update domain if verification status changed
|
||||||
if (attributes.status === 'Success' && !domain.verified) {
|
if (attributes.status === 'Success' && !domain.verified) {
|
||||||
await prisma.domain.update({
|
const updatedDomain = await prisma.domain.update({
|
||||||
where: {id: domainId},
|
where: {id: domainId},
|
||||||
data: {verified: true},
|
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) {
|
} else if (attributes.status !== 'Success' && domain.verified) {
|
||||||
await prisma.domain.update({
|
const updatedDomain = await prisma.domain.update({
|
||||||
where: {id: domainId},
|
where: {id: domainId},
|
||||||
data: {verified: false},
|
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 {
|
return {
|
||||||
@@ -83,7 +108,14 @@ export class DomainService {
|
|||||||
* Remove a domain from a project
|
* Remove a domain from a project
|
||||||
*/
|
*/
|
||||||
public static async removeDomain(domainId: string) {
|
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) {
|
if (!domain) {
|
||||||
throw new Error('Domain not found');
|
throw new Error('Domain not found');
|
||||||
@@ -152,6 +184,9 @@ export class DomainService {
|
|||||||
|
|
||||||
await prisma.domain.delete({where: {id: domainId}});
|
await prisma.domain.delete({where: {id: domainId}});
|
||||||
|
|
||||||
|
// Send notification about domain removal
|
||||||
|
await NtfyService.notifyDomainRemoved(domainName, domain.project.name, domain.project.id);
|
||||||
|
|
||||||
return true;
|
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 {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {QueueService} from './QueueService.js';
|
import {QueueService} from './QueueService.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,6 +113,17 @@ export class SecurityService {
|
|||||||
} else if (status.warnings.length > 0) {
|
} else if (status.warnings.length > 0) {
|
||||||
// Log warnings for monitoring
|
// Log warnings for monitoring
|
||||||
signale.warn(`[SECURITY] Project ${projectId} has security warnings:`, status.warnings);
|
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) {
|
} catch (error) {
|
||||||
// Log error but don't throw - we don't want security checks to break the webhook
|
// 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);
|
signale.error(`[SECURITY] Failed to cancel pending jobs for project ${projectId}:`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Send notification to project owners about the suspension
|
// Send urgent notification about project suspension
|
||||||
// This could be implemented using the notification service or email alert
|
await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, status.violations);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[SECURITY] Failed to disable project ${projectId}:`, 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 {DASHBOARD_URI} from '../app/constants.js';
|
||||||
|
|
||||||
import {EmailService} from './EmailService.js';
|
import {EmailService} from './EmailService.js';
|
||||||
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {QueueService} from './QueueService.js';
|
import {QueueService} from './QueueService.js';
|
||||||
|
|
||||||
// Type aliases for workflow execution context
|
// Type aliases for workflow execution context
|
||||||
@@ -259,14 +260,36 @@ export class WorkflowExecutionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mark workflow execution as failed
|
// Mark workflow execution as failed
|
||||||
await prisma.workflowExecution.update({
|
const failedExecution = await prisma.workflowExecution.update({
|
||||||
where: {id: executionId},
|
where: {id: executionId},
|
||||||
data: {
|
data: {
|
||||||
status: WorkflowExecutionStatus.FAILED,
|
status: WorkflowExecutionStatus.FAILED,
|
||||||
completedAt: new Date(),
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import {Prisma, WorkflowExecutionStatus} from '@plunk/db';
|
|||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
|
|
||||||
import {EventService} from './EventService.js';
|
|
||||||
import {ContactService} from './ContactService.js';
|
import {ContactService} from './ContactService.js';
|
||||||
|
import {EventService} from './EventService.js';
|
||||||
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
|
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
|
||||||
|
|
||||||
export interface PaginatedWorkflows {
|
export interface PaginatedWorkflows {
|
||||||
@@ -171,6 +172,17 @@ export class WorkflowService {
|
|||||||
await EventService.invalidateWorkflowCache(projectId);
|
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;
|
return workflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +237,11 @@ export class WorkflowService {
|
|||||||
const updated = await prisma.workflow.update({
|
const updated = await prisma.workflow.update({
|
||||||
where: {id: workflowId},
|
where: {id: workflowId},
|
||||||
data: updateData,
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: {name: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Invalidate workflow cache if enabled status changed or workflow is enabled
|
// Invalidate workflow cache if enabled status changed or workflow is enabled
|
||||||
@@ -232,6 +249,15 @@ export class WorkflowService {
|
|||||||
await EventService.invalidateWorkflowCache(projectId);
|
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;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,8 +265,19 @@ export class WorkflowService {
|
|||||||
* Delete a workflow
|
* Delete a workflow
|
||||||
*/
|
*/
|
||||||
public static async delete(projectId: string, workflowId: string): Promise<void> {
|
public static async delete(projectId: string, workflowId: string): Promise<void> {
|
||||||
// Verify workflow exists and belongs to project
|
// Verify workflow exists and belongs to project - get with project name
|
||||||
const workflow = await this.get(projectId, workflowId);
|
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
|
// Check if workflow has active executions
|
||||||
const activeExecutions = await this.hasActiveExecutions(workflowId);
|
const activeExecutions = await this.hasActiveExecutions(workflowId);
|
||||||
@@ -260,6 +297,9 @@ export class WorkflowService {
|
|||||||
if (workflow.enabled) {
|
if (workflow.enabled) {
|
||||||
await EventService.invalidateWorkflowCache(projectId);
|
await EventService.invalidateWorkflowCache(projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send notification about workflow deletion
|
||||||
|
await NtfyService.notifyWorkflowDeleted(workflow.name, workflow.project.name, projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -82,6 +82,28 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- plunk
|
- plunk
|
||||||
|
|
||||||
|
ntfy:
|
||||||
|
image: binwiederhier/ntfy:latest
|
||||||
|
container_name: plunk-ntfy
|
||||||
|
restart: unless-stopped
|
||||||
|
command: serve
|
||||||
|
environment:
|
||||||
|
- TZ=UTC
|
||||||
|
volumes:
|
||||||
|
- ntfy_cache:/var/cache/ntfy
|
||||||
|
- ntfy_etc:/etc/ntfy
|
||||||
|
ports:
|
||||||
|
# Expose ntfy web UI and API
|
||||||
|
- "${NTFY_PORT:-8080}:80"
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1" ]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- plunk
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Plunk Application
|
# Plunk Application
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -153,6 +175,9 @@ services:
|
|||||||
S3_PUBLIC_URL: ${S3_PUBLIC_URL:-http://localhost:9000/uploads}
|
S3_PUBLIC_URL: ${S3_PUBLIC_URL:-http://localhost:9000/uploads}
|
||||||
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-true}
|
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-true}
|
||||||
|
|
||||||
|
# Notifications (ntfy.sh)
|
||||||
|
NTFY_URL: ${NTFY_URL:-http://ntfy/plunk-notifications}
|
||||||
|
|
||||||
# SMTP Server (for sending emails via SMTP)
|
# SMTP Server (for sending emails via SMTP)
|
||||||
# SMTP_DOMAIN is required to select the correct certificate from Traefik acme.json
|
# SMTP_DOMAIN is required to select the correct certificate from Traefik acme.json
|
||||||
# If using PEM files, SMTP_DOMAIN is optional
|
# If using PEM files, SMTP_DOMAIN is optional
|
||||||
@@ -185,6 +210,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
ntfy:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
# Check nginx is running and responding
|
# Check nginx is running and responding
|
||||||
@@ -206,6 +233,10 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
plunk_data:
|
plunk_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
ntfy_cache:
|
||||||
|
driver: local
|
||||||
|
ntfy_etc:
|
||||||
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
plunk:
|
plunk:
|
||||||
|
|||||||
Reference in New Issue
Block a user