diff --git a/apps/api/src/email/templates/automation-failures.tsx b/apps/api/src/email/templates/automation-failures.tsx
new file mode 100644
index 000000000..5a5f5f5e0
--- /dev/null
+++ b/apps/api/src/email/templates/automation-failures.tsx
@@ -0,0 +1,125 @@
+import * as React from 'react';
+import {
+ Body,
+ Button,
+ Container,
+ Font,
+ Heading,
+ Html,
+ Link,
+ Preview,
+ Section,
+ Tailwind,
+ Text,
+} from '@react-email/components';
+import { Footer } from '../components/footer';
+import { Logo } from '../components/logo';
+import { getUnsubscribeUrl } from '@trycompai/email';
+
+interface Props {
+ toName: string;
+ toEmail: string;
+ taskTitle: string;
+ failedCount: number;
+ totalCount: number;
+ taskStatusChanged: boolean;
+ organizationName: string;
+ taskUrl: string;
+}
+
+export const AutomationFailuresEmail = ({
+ toName,
+ toEmail,
+ taskTitle,
+ failedCount,
+ totalCount,
+ taskStatusChanged,
+ organizationName,
+ taskUrl,
+}: Props) => {
+ const unsubscribeUrl = getUnsubscribeUrl(toEmail);
+
+ return (
+
+
+
+
+
+
+
+ {`${failedCount} of ${totalCount} automation(s) failed on task "${taskTitle}"`}
+
+
+
+
+
+
+ Automation Failures
+
+
+
+ Hello {toName},
+
+
+
+ {failedCount} of {totalCount} automation(s)
+ failed on task "{taskTitle}" in{' '}
+ {organizationName}.
+
+
+ {taskStatusChanged && (
+
+ Task status has been changed to Failed.
+
+ )}
+
+
+
+
+ or copy and paste this URL into your browser:{' '}
+
+ {taskUrl}
+
+
+
+
+
+ Don't want to receive task assignment notifications?{' '}
+
+ Manage your email preferences
+
+ .
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default AutomationFailuresEmail;
diff --git a/apps/api/src/tasks/internal-task-notification.controller.ts b/apps/api/src/tasks/internal-task-notification.controller.ts
new file mode 100644
index 000000000..79818c9ce
--- /dev/null
+++ b/apps/api/src/tasks/internal-task-notification.controller.ts
@@ -0,0 +1,147 @@
+import {
+ Body,
+ Controller,
+ HttpCode,
+ InternalServerErrorException,
+ Logger,
+ Post,
+ UseGuards,
+} from '@nestjs/common';
+import { ApiHeader, ApiOperation, ApiProperty, ApiResponse, ApiTags } from '@nestjs/swagger';
+import { TaskStatus } from '@db';
+import { IsBoolean, IsEnum, IsInt, IsString, Min } from 'class-validator';
+import { InternalTokenGuard } from '../auth/internal-token.guard';
+import { TaskNotifierService } from './task-notifier.service';
+
+const TaskStatusValues = Object.values(TaskStatus);
+
+class NotifyAutomationFailuresDto {
+ @ApiProperty({ description: 'Organization ID' })
+ @IsString()
+ organizationId: string;
+
+ @ApiProperty({ description: 'Task ID' })
+ @IsString()
+ taskId: string;
+
+ @ApiProperty({ description: 'Task title' })
+ @IsString()
+ taskTitle: string;
+
+ @ApiProperty({ description: 'Number of failed automations' })
+ @IsInt()
+ @Min(1)
+ failedCount: number;
+
+ @ApiProperty({ description: 'Total number of automations' })
+ @IsInt()
+ @Min(1)
+ totalCount: number;
+
+ @ApiProperty({ description: 'Whether task status was changed to failed' })
+ @IsBoolean()
+ taskStatusChanged: boolean;
+}
+
+class NotifyStatusChangeDto {
+ @ApiProperty({ description: 'Organization ID' })
+ @IsString()
+ organizationId: string;
+
+ @ApiProperty({ description: 'Task ID' })
+ @IsString()
+ taskId: string;
+
+ @ApiProperty({ description: 'Task title' })
+ @IsString()
+ taskTitle: string;
+
+ @ApiProperty({ description: 'Previous task status', enum: TaskStatusValues })
+ @IsEnum(TaskStatusValues)
+ oldStatus: TaskStatus;
+
+ @ApiProperty({ description: 'New task status', enum: TaskStatusValues })
+ @IsEnum(TaskStatusValues)
+ newStatus: TaskStatus;
+}
+
+@ApiTags('Internal - Tasks')
+@Controller({ path: 'internal/tasks', version: '1' })
+@UseGuards(InternalTokenGuard)
+@ApiHeader({
+ name: 'X-Internal-Token',
+ description: 'Internal service token (required in production)',
+ required: false,
+})
+export class InternalTaskNotificationController {
+ private readonly logger = new Logger(InternalTaskNotificationController.name);
+
+ constructor(private readonly taskNotifierService: TaskNotifierService) {}
+
+ @Post('notify-status-change')
+ @HttpCode(200)
+ @ApiOperation({
+ summary:
+ 'Send task status change notifications (email + in-app) without a user actor (internal)',
+ })
+ @ApiResponse({ status: 200, description: 'Notifications sent' })
+ @ApiResponse({ status: 500, description: 'Notification delivery failed' })
+ async notifyStatusChange(@Body() body: NotifyStatusChangeDto) {
+ this.logger.log(
+ `[notifyStatusChange] Received request for task ${body.taskId} (${body.oldStatus} -> ${body.newStatus})`,
+ );
+
+ try {
+ await this.taskNotifierService.notifyStatusChange({
+ organizationId: body.organizationId,
+ taskId: body.taskId,
+ taskTitle: body.taskTitle,
+ oldStatus: body.oldStatus,
+ newStatus: body.newStatus,
+ });
+
+ return { success: true };
+ } catch (error) {
+ this.logger.error(
+ `[notifyStatusChange] Failed for task ${body.taskId}:`,
+ error instanceof Error ? error.message : 'Unknown error',
+ );
+
+ throw new InternalServerErrorException('Failed to send notifications');
+ }
+ }
+
+ @Post('notify-automation-failures')
+ @HttpCode(200)
+ @ApiOperation({
+ summary:
+ 'Send automation failure notifications (email + in-app) when one or more automations fail (internal)',
+ })
+ @ApiResponse({ status: 200, description: 'Notifications sent' })
+ @ApiResponse({ status: 500, description: 'Notification delivery failed' })
+ async notifyAutomationFailures(@Body() body: NotifyAutomationFailuresDto) {
+ this.logger.log(
+ `[notifyAutomationFailures] Received request for task ${body.taskId} (${body.failedCount}/${body.totalCount} failed, statusChanged=${body.taskStatusChanged})`,
+ );
+
+ try {
+ await this.taskNotifierService.notifyAutomationFailures({
+ organizationId: body.organizationId,
+ taskId: body.taskId,
+ taskTitle: body.taskTitle,
+ failedCount: body.failedCount,
+ totalCount: body.totalCount,
+ taskStatusChanged: body.taskStatusChanged,
+ });
+
+ return { success: true };
+ } catch (error) {
+ this.logger.error(
+ `[notifyAutomationFailures] Failed for task ${body.taskId}:`,
+ error instanceof Error ? error.message : 'Unknown error',
+ );
+
+ throw new InternalServerErrorException('Failed to send notifications');
+ }
+ }
+}
diff --git a/apps/api/src/tasks/task-notifier.service.ts b/apps/api/src/tasks/task-notifier.service.ts
index b8fae1b90..ca3ef4a15 100644
--- a/apps/api/src/tasks/task-notifier.service.ts
+++ b/apps/api/src/tasks/task-notifier.service.ts
@@ -9,6 +9,7 @@ import { TaskStatusChangedEmail } from '../email/templates/task-status-changed';
import { TaskAssigneeChangedEmail } from '../email/templates/task-assignee-changed';
import { EvidenceReviewRequestedEmail } from '../email/templates/evidence-review-requested';
import { EvidenceBulkReviewRequestedEmail } from '../email/templates/evidence-bulk-review-requested';
+import { AutomationFailuresEmail } from '../email/templates/automation-failures';
import { NovuService } from '../notifications/novu.service';
const BULK_TASK_WORKFLOW_ID = 'evidence-bulk-updated';
@@ -399,7 +400,7 @@ export class TaskNotifierService {
taskTitle: string;
oldStatus: TaskStatus;
newStatus: TaskStatus;
- changedByUserId: string;
+ changedByUserId?: string;
}): Promise {
const {
organizationId,
@@ -417,10 +418,12 @@ export class TaskNotifierService {
where: { id: organizationId },
select: { name: true },
}),
- db.user.findUnique({
- where: { id: changedByUserId },
- select: { name: true, email: true },
- }),
+ changedByUserId
+ ? db.user.findUnique({
+ where: { id: changedByUserId },
+ select: { name: true, email: true },
+ })
+ : Promise.resolve(null),
db.task.findUnique({
where: { id: taskId },
select: {
@@ -475,7 +478,7 @@ export class TaskNotifierService {
const changedByName =
changedByUser?.name?.trim() ||
changedByUser?.email?.trim() ||
- 'Someone';
+ (changedByUserId ? 'Someone' : 'Automation');
const oldStatusLabel = oldStatus.replace('_', ' ');
const newStatusLabel = newStatus.replace('_', ' ');
@@ -1096,4 +1099,202 @@ export class TaskNotifierService {
);
}
}
+
+ async notifyAutomationFailures(params: {
+ organizationId: string;
+ taskId: string;
+ taskTitle: string;
+ failedCount: number;
+ totalCount: number;
+ taskStatusChanged: boolean;
+ }): Promise {
+ const {
+ organizationId,
+ taskId,
+ taskTitle,
+ failedCount,
+ totalCount,
+ taskStatusChanged,
+ } = params;
+
+ try {
+ const [organization, task, allMembers] = await Promise.all([
+ db.organization.findUnique({
+ where: { id: organizationId },
+ select: { name: true },
+ }),
+ db.task.findUnique({
+ where: { id: taskId },
+ select: {
+ assignee: {
+ select: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ },
+ },
+ },
+ }),
+ db.member.findMany({
+ where: {
+ organizationId,
+ deactivated: false,
+ },
+ select: {
+ id: true,
+ role: true,
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ },
+ }),
+ ]);
+
+ // Filter for admins/owners (roles can be comma-separated, e.g., "admin,auditor")
+ const adminMembers = allMembers.filter(
+ (member) =>
+ member.role &&
+ (member.role.includes('admin') || member.role.includes('owner')),
+ );
+
+ this.logger.debug(
+ `[notifyAutomationFailures] Found ${allMembers.length} total members, ${adminMembers.length} admins/owners for organization ${organizationId}`,
+ );
+
+ const organizationName = organization?.name ?? 'your organization';
+ const changedByName = 'Automation';
+
+ // Build recipient list: assignee + admins
+ const recipientMap = new Map<
+ string,
+ { id: string; name: string; email: string }
+ >();
+
+ // Add assignee if exists
+ if (task?.assignee?.user?.id && task.assignee.user.email) {
+ const userId = task.assignee.user.id;
+ recipientMap.set(userId, {
+ id: userId,
+ name:
+ task.assignee.user.name?.trim() ||
+ task.assignee.user.email?.trim() ||
+ 'User',
+ email: task.assignee.user.email,
+ });
+ }
+
+ // Add admin members
+ for (const member of adminMembers) {
+ if (member.user?.id && member.user.email) {
+ const userId = member.user.id;
+ recipientMap.set(userId, {
+ id: userId,
+ name:
+ member.user.name?.trim() || member.user.email?.trim() || 'User',
+ email: member.user.email,
+ });
+ }
+ }
+
+ const recipients = Array.from(recipientMap.values());
+
+ const appUrl =
+ process.env.NEXT_PUBLIC_APP_URL ??
+ process.env.BETTER_AUTH_URL ??
+ 'https://app.trycomp.ai';
+ const taskUrl = `${appUrl}/${organizationId}/tasks/${taskId}`;
+
+ this.logger.log(
+ `Sending automation failure notifications to ${recipients.length} recipients for task "${taskTitle}"`,
+ );
+
+ // Send notifications to each recipient
+ await Promise.allSettled(
+ recipients.map(async (recipient) => {
+ const isUnsubscribed = await isUserUnsubscribed(
+ db,
+ recipient.email,
+ 'taskAssignments',
+ );
+
+ if (isUnsubscribed) {
+ this.logger.log(
+ `Skipping notification: user ${recipient.email} is unsubscribed from task assignments`,
+ );
+ return;
+ }
+
+ // Send email notification
+ try {
+ const { id } = await sendEmail({
+ to: recipient.email,
+ subject: `Automation failures on task "${taskTitle}"`,
+ react: AutomationFailuresEmail({
+ toName: recipient.name,
+ toEmail: recipient.email,
+ taskTitle,
+ failedCount,
+ totalCount,
+ taskStatusChanged,
+ organizationName,
+ taskUrl,
+ }),
+ system: true,
+ });
+
+ this.logger.log(
+ `Automation failure email sent to ${recipient.email} (ID: ${id})`,
+ );
+ } catch (error) {
+ this.logger.error(
+ `Failed to send automation failure email to ${recipient.email}:`,
+ error instanceof Error ? error.message : 'Unknown error',
+ );
+ }
+
+ // Send in-app notification
+ try {
+ const title = `Automation failures on task`;
+ const statusSuffix = taskStatusChanged
+ ? '. Task status has been changed to Failed.'
+ : '';
+ const message = `${failedCount} of ${totalCount} automation(s) failed on "${taskTitle}" in ${organizationName}${statusSuffix}`;
+
+ await this.novuService.trigger({
+ workflowId: TASK_WORKFLOW_ID,
+ subscriberId: `${recipient.id}-${organizationId}`,
+ email: recipient.email,
+ payload: {
+ title,
+ message,
+ url: taskUrl,
+ },
+ });
+
+ this.logger.log(
+ `[NOVU] Automation failure in-app notification sent to ${recipient.id}`,
+ );
+ } catch (error) {
+ this.logger.error(
+ `[NOVU] Failed to send automation failure in-app notification to ${recipient.id}:`,
+ error instanceof Error ? error.message : 'Unknown error',
+ );
+ }
+ }),
+ );
+ } catch (error) {
+ this.logger.error(
+ 'Failed to send automation failure notifications',
+ error as Error,
+ );
+ }
+ }
}
diff --git a/apps/api/src/tasks/tasks.module.ts b/apps/api/src/tasks/tasks.module.ts
index 888d2503e..817b452df 100644
--- a/apps/api/src/tasks/tasks.module.ts
+++ b/apps/api/src/tasks/tasks.module.ts
@@ -3,13 +3,14 @@ import { AttachmentsModule } from '../attachments/attachments.module';
import { AuthModule } from '../auth/auth.module';
import { AutomationsModule } from './automations/automations.module';
import { NovuService } from '../notifications/novu.service';
+import { InternalTaskNotificationController } from './internal-task-notification.controller';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
import { TaskNotifierService } from './task-notifier.service';
@Module({
imports: [AuthModule, AttachmentsModule, forwardRef(() => AutomationsModule)],
- controllers: [TasksController],
+ controllers: [TasksController, InternalTaskNotificationController],
providers: [TasksService, TaskNotifierService, NovuService],
exports: [TasksService, TaskNotifierService],
})
diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json
index c5738bf92..f36f416b6 100644
--- a/packages/docs/openapi.json
+++ b/packages/docs/openapi.json
@@ -8212,6 +8212,44 @@
]
}
},
+ "/v1/internal/tasks/notify-status-change": {
+ "post": {
+ "operationId": "InternalTaskNotificationController_notifyStatusChange_v1",
+ "parameters": [
+ {
+ "name": "X-Internal-Token",
+ "in": "header",
+ "description": "Internal service token (required in production)",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NotifyStatusChangeDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Notifications sent"
+ },
+ "500": {
+ "description": "Notification delivery failed"
+ }
+ },
+ "summary": "Send task status change notifications (email + in-app) without a user actor (internal)",
+ "tags": [
+ "Internal - Tasks"
+ ]
+ }
+ },
"/v1/tasks/{taskId}/automations": {
"get": {
"description": "Retrieve all automations for a specific task",
@@ -18505,6 +18543,54 @@
"fileData"
]
},
+ "NotifyStatusChangeDto": {
+ "type": "object",
+ "properties": {
+ "organizationId": {
+ "type": "string",
+ "description": "Organization ID"
+ },
+ "taskId": {
+ "type": "string",
+ "description": "Task ID"
+ },
+ "taskTitle": {
+ "type": "string",
+ "description": "Task title"
+ },
+ "oldStatus": {
+ "type": "string",
+ "description": "Previous task status",
+ "enum": [
+ "todo",
+ "in_progress",
+ "in_review",
+ "done",
+ "not_relevant",
+ "failed"
+ ]
+ },
+ "newStatus": {
+ "type": "string",
+ "description": "New task status",
+ "enum": [
+ "todo",
+ "in_progress",
+ "in_review",
+ "done",
+ "not_relevant",
+ "failed"
+ ]
+ }
+ },
+ "required": [
+ "organizationId",
+ "taskId",
+ "taskTitle",
+ "oldStatus",
+ "newStatus"
+ ]
+ },
"UpdateAutomationDto": {
"type": "object",
"properties": {