Skip to content

Commit c547f28

Browse files
[dev] [tofikwest] tofik/email-notification-for-failed-task (#2103)
* fix(api): resend email notifications for failed automations + format code for better readability and consistency * feat(app): add self-hosted mode support and update organization access logic * feat(email): add unsubscribe check for task assignment notifications * fix(api): prevent duplicate email notifications for failed tasks --------- Co-authored-by: Tofik Hasanov <annexcies@gmail.com> Co-authored-by: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com>
1 parent d88c824 commit c547f28

28 files changed

Lines changed: 1089 additions & 360 deletions

SELF_HOSTING.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ For a functional deployment:
2727
- **Workflows**: `TRIGGER_SECRET_KEY` in app
2828
- **Misc**: `REVALIDATION_SECRET`, `NEXT_PUBLIC_PORTAL_URL` in app
2929

30+
**Self-Hosted Mode:**
31+
- Set `NEXT_PUBLIC_SELF_HOSTED=true` in `apps/app/.env` to mark the instance as self-hosted
32+
- When enabled, organizations are automatically approved and bypass the payment/booking flow
33+
- `STRIPE_SECRET_KEY` is not required for self-hosted instances
34+
3035
### Prerequisites
3136

3237
- Docker Desktop or Docker Engine

apps/api/src/finding-template/finding-template.service.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,7 @@ export class FindingTemplateService {
3131
throw new NotFoundException(`Finding template with ID ${id} not found`);
3232
}
3333

34-
this.logger.log(
35-
`Retrieved finding template: ${template.title} (${id})`,
36-
);
34+
this.logger.log(`Retrieved finding template: ${template.title} (${id})`);
3735
return template;
3836
} catch (error) {
3937
if (error instanceof NotFoundException) {

apps/api/src/findings/dto/update-finding.dto.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ export class UpdateFindingDto {
4141
content?: string;
4242

4343
@ApiProperty({
44-
description: 'Auditor note when requesting revision (only for needs_revision status)',
44+
description:
45+
'Auditor note when requesting revision (only for needs_revision status)',
4546
example: 'Please provide clearer screenshots showing the timestamp.',
4647
maxLength: 2000,
4748
required: false,

apps/api/src/findings/finding-notifier.service.ts

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ const NOVU_CONTENT_MAX_LENGTH = 100;
1717
// Types
1818
// ============================================================================
1919

20-
type FindingAction = 'created' | 'ready_for_review' | 'needs_revision' | 'closed';
20+
type FindingAction =
21+
| 'created'
22+
| 'ready_for_review'
23+
| 'needs_revision'
24+
| 'closed';
2125

2226
interface Recipient {
2327
userId: string;
@@ -99,9 +103,20 @@ export class FindingNotifierService {
99103
* Recipients: Task assignee + Organization admins/owners
100104
*/
101105
async notifyFindingCreated(params: NotificationParams): Promise<void> {
102-
const { organizationId, taskId, taskTitle, findingType, actorUserId, actorName } = params;
106+
const {
107+
organizationId,
108+
taskId,
109+
taskTitle,
110+
findingType,
111+
actorUserId,
112+
actorName,
113+
} = params;
103114

104-
const recipients = await this.getTaskAssigneeAndAdmins(organizationId, taskId, actorUserId);
115+
const recipients = await this.getTaskAssigneeAndAdmins(
116+
organizationId,
117+
taskId,
118+
actorUserId,
119+
);
105120

106121
if (recipients.length === 0) {
107122
this.logger.log('No recipients for finding created notification');
@@ -125,13 +140,22 @@ export class FindingNotifierService {
125140
async notifyReadyForReview(
126141
params: NotificationParams & { findingCreatorMemberId: string },
127142
): Promise<void> {
128-
const { findingId, taskTitle, actorUserId, actorName, findingCreatorMemberId } = params;
143+
const {
144+
findingId,
145+
taskTitle,
146+
actorUserId,
147+
actorName,
148+
findingCreatorMemberId,
149+
} = params;
129150

130151
this.logger.log(
131152
`[notifyReadyForReview] Finding ${findingId}: Looking for creator (memberId: ${findingCreatorMemberId}), excluding actor (userId: ${actorUserId})`,
132153
);
133154

134-
const recipients = await this.getFindingCreator(findingCreatorMemberId, actorUserId);
155+
const recipients = await this.getFindingCreator(
156+
findingCreatorMemberId,
157+
actorUserId,
158+
);
135159

136160
if (recipients.length === 0) {
137161
this.logger.warn(
@@ -160,9 +184,14 @@ export class FindingNotifierService {
160184
* Recipients: Task assignee + Organization admins/owners
161185
*/
162186
async notifyNeedsRevision(params: NotificationParams): Promise<void> {
163-
const { organizationId, taskId, taskTitle, actorUserId, actorName } = params;
187+
const { organizationId, taskId, taskTitle, actorUserId, actorName } =
188+
params;
164189

165-
const recipients = await this.getTaskAssigneeAndAdmins(organizationId, taskId, actorUserId);
190+
const recipients = await this.getTaskAssigneeAndAdmins(
191+
organizationId,
192+
taskId,
193+
actorUserId,
194+
);
166195

167196
if (recipients.length === 0) {
168197
this.logger.log('No recipients for needs revision notification');
@@ -185,9 +214,14 @@ export class FindingNotifierService {
185214
* Recipients: Task assignee + Organization admins/owners
186215
*/
187216
async notifyFindingClosed(params: NotificationParams): Promise<void> {
188-
const { organizationId, taskId, taskTitle, actorUserId, actorName } = params;
217+
const { organizationId, taskId, taskTitle, actorUserId, actorName } =
218+
params;
189219

190-
const recipients = await this.getTaskAssigneeAndAdmins(organizationId, taskId, actorUserId);
220+
const recipients = await this.getTaskAssigneeAndAdmins(
221+
organizationId,
222+
taskId,
223+
actorUserId,
224+
);
191225

192226
if (recipients.length === 0) {
193227
this.logger.log('No recipients for finding closed notification');
@@ -213,7 +247,9 @@ export class FindingNotifierService {
213247
* Send notifications to all recipients via email and in-app (Novu).
214248
* Failures are logged but don't throw - fire-and-forget pattern.
215249
*/
216-
private async sendNotifications(params: SendNotificationParams): Promise<void> {
250+
private async sendNotifications(
251+
params: SendNotificationParams,
252+
): Promise<void> {
217253
const {
218254
organizationId,
219255
findingId,
@@ -301,10 +337,16 @@ export class FindingNotifierService {
301337

302338
try {
303339
// Check unsubscribe preferences
304-
const isUnsubscribed = await isUserUnsubscribed(db, recipient.email, 'findingNotifications');
340+
const isUnsubscribed = await isUserUnsubscribed(
341+
db,
342+
recipient.email,
343+
'findingNotifications',
344+
);
305345

306346
if (isUnsubscribed) {
307-
this.logger.log(`Skipping notification: ${recipient.email} is unsubscribed`);
347+
this.logger.log(
348+
`Skipping notification: ${recipient.email} is unsubscribed`,
349+
);
308350
return;
309351
}
310352

@@ -320,7 +362,10 @@ export class FindingNotifierService {
320362
taskTitle,
321363
organizationName,
322364
findingType,
323-
findingContent: truncateContent(findingContent, EMAIL_CONTENT_MAX_LENGTH),
365+
findingContent: truncateContent(
366+
findingContent,
367+
EMAIL_CONTENT_MAX_LENGTH,
368+
),
324369
newStatus,
325370
findingUrl,
326371
}),
@@ -332,7 +377,10 @@ export class FindingNotifierService {
332377
taskId,
333378
taskTitle,
334379
findingType,
335-
findingContent: truncateContent(findingContent, NOVU_CONTENT_MAX_LENGTH),
380+
findingContent: truncateContent(
381+
findingContent,
382+
NOVU_CONTENT_MAX_LENGTH,
383+
),
336384
action,
337385
heading,
338386
message,
@@ -508,15 +556,20 @@ export class FindingNotifierService {
508556

509557
// Filter for admins/owners (roles can be comma-separated, e.g., "admin,auditor")
510558
const adminMembers = allMembers.filter(
511-
(member) => member.role.includes('admin') || member.role.includes('owner'),
559+
(member) =>
560+
member.role.includes('admin') || member.role.includes('owner'),
512561
);
513562

514563
const recipients: Recipient[] = [];
515564
const addedUserIds = new Set<string>();
516565

517566
// Add task assignee
518567
const assigneeUser = task?.assignee?.user;
519-
if (assigneeUser && assigneeUser.id !== excludeUserId && assigneeUser.email) {
568+
if (
569+
assigneeUser &&
570+
assigneeUser.id !== excludeUserId &&
571+
assigneeUser.email
572+
) {
520573
recipients.push({
521574
userId: assigneeUser.id,
522575
email: assigneeUser.email,
@@ -528,7 +581,11 @@ export class FindingNotifierService {
528581
// Add org admins/owners (deduplicated)
529582
for (const member of adminMembers) {
530583
const user = member.user;
531-
if (user.id !== excludeUserId && user.email && !addedUserIds.has(user.id)) {
584+
if (
585+
user.id !== excludeUserId &&
586+
user.email &&
587+
!addedUserIds.has(user.id)
588+
) {
532589
recipients.push({
533590
userId: user.id,
534591
email: user.email,
@@ -552,7 +609,10 @@ export class FindingNotifierService {
552609
* Get the finding creator as recipient (for Ready for Review notifications).
553610
* Excludes the actor (person who triggered the action).
554611
*/
555-
private async getFindingCreator(creatorMemberId: string, excludeUserId: string): Promise<Recipient[]> {
612+
private async getFindingCreator(
613+
creatorMemberId: string,
614+
excludeUserId: string,
615+
): Promise<Recipient[]> {
556616
try {
557617
const member = await db.member.findUnique({
558618
where: { id: creatorMemberId },

apps/api/src/findings/findings.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,8 @@ export class FindingsService {
288288
) {
289289
// Verify finding exists and get current state for audit
290290
const finding = await this.findById(organizationId, findingId);
291-
const previousStatus = finding.status as FindingStatus;
292-
const previousType = finding.type as FindingType;
291+
const previousStatus = finding.status;
292+
const previousType = finding.type;
293293
const previousContent = finding.content;
294294

295295
// Validate status transition permissions
@@ -421,7 +421,7 @@ export class FindingsService {
421421
taskId: finding.taskId,
422422
taskTitle: finding.task.title,
423423
findingContent: updatedFinding.content,
424-
findingType: updatedFinding.type as FindingType,
424+
findingType: updatedFinding.type,
425425
actorUserId: userId,
426426
actorName,
427427
};

apps/api/src/integration-platform/controllers/variables.controller.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,9 @@ export class VariablesController {
228228
await this.credentialVaultService.getDecryptedCredentials(connectionId);
229229

230230
const accessToken =
231-
typeof credentials?.access_token === 'string' ? credentials.access_token : undefined;
231+
typeof credentials?.access_token === 'string'
232+
? credentials.access_token
233+
: undefined;
232234
if (!accessToken) {
233235
throw new HttpException(
234236
'No valid credentials found',

apps/api/src/integration-platform/services/connection-auth-teardown.service.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ export class ConnectionAuthTeardownService {
3131
const credentials =
3232
await this.credentialVaultService.getDecryptedCredentials(connectionId);
3333
const accessToken =
34-
typeof credentials?.access_token === 'string' ? credentials.access_token : undefined;
34+
typeof credentials?.access_token === 'string'
35+
? credentials.access_token
36+
: undefined;
3537

3638
if (providerSlug && accessToken) {
3739
try {

apps/api/src/integration-platform/services/credential-vault.service.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,9 @@ export class CredentialVaultService {
171171

172172
if (Array.isArray(value)) {
173173
const encryptedItems = await Promise.all(
174-
value.map((item) => (typeof item === 'string' ? this.encrypt(item) : item)),
174+
value.map((item) =>
175+
typeof item === 'string' ? this.encrypt(item) : item,
176+
),
175177
);
176178
encryptedPayload[key] = encryptedItems;
177179
continue;
@@ -289,7 +291,9 @@ export class CredentialVaultService {
289291
*/
290292
async getRefreshToken(connectionId: string): Promise<string | null> {
291293
const credentials = await this.getDecryptedCredentials(connectionId);
292-
return typeof credentials?.refresh_token === 'string' ? credentials.refresh_token : null;
294+
return typeof credentials?.refresh_token === 'string'
295+
? credentials.refresh_token
296+
: null;
293297
}
294298

295299
/**
@@ -414,6 +418,8 @@ export class CredentialVaultService {
414418

415419
// Get current credentials
416420
const credentials = await this.getDecryptedCredentials(connectionId);
417-
return typeof credentials?.access_token === 'string' ? credentials.access_token : null;
421+
return typeof credentials?.access_token === 'string'
422+
? credentials.access_token
423+
: null;
418424
}
419425
}

apps/api/src/policies/dto/version.dto.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ export class CreateVersionDto {
2323

2424
export class UpdateVersionContentDto {
2525
@ApiProperty({
26-
description: 'Content of the policy version as TipTap JSON (array of nodes)',
26+
description:
27+
'Content of the policy version as TipTap JSON (array of nodes)',
2728
example: [
2829
{
2930
type: 'heading',

apps/api/src/policies/policies.service.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -333,9 +333,14 @@ export class PoliciesService {
333333
if (pdfUrlsToDelete.length > 0) {
334334
await Promise.allSettled(
335335
pdfUrlsToDelete.map((pdfUrl) =>
336-
this.attachmentsService.deletePolicyVersionPdf(pdfUrl).catch((err) => {
337-
this.logger.warn(`Failed to delete PDF from S3: ${pdfUrl}`, err);
338-
}),
336+
this.attachmentsService
337+
.deletePolicyVersionPdf(pdfUrl)
338+
.catch((err) => {
339+
this.logger.warn(
340+
`Failed to delete PDF from S3: ${pdfUrl}`,
341+
err,
342+
);
343+
}),
339344
),
340345
);
341346
}
@@ -799,7 +804,9 @@ export class PoliciesService {
799804

800805
// Cannot assign a deactivated member as approver - they can't log in to approve
801806
if (approver.deactivated) {
802-
throw new BadRequestException('Cannot assign a deactivated member as approver');
807+
throw new BadRequestException(
808+
'Cannot assign a deactivated member as approver',
809+
);
803810
}
804811

805812
await db.policy.update({
@@ -951,9 +958,8 @@ export class PoliciesService {
951958

952959
if (hasUploadedPdf) {
953960
try {
954-
const pdfBuffer = await this.attachmentsService.getObjectBuffer(
955-
pdfUrl!,
956-
);
961+
const pdfBuffer =
962+
await this.attachmentsService.getObjectBuffer(pdfUrl);
957963
return {
958964
policy,
959965
pdfBuffer: Buffer.from(pdfBuffer),

0 commit comments

Comments
 (0)