Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions apps/api/src/email/templates/automation-failures.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Html>
<Tailwind>
<head>
<Font
fontFamily="Geist"
fallbackFontFamily="Helvetica"
fontWeight={400}
fontStyle="normal"
/>
<Font
fontFamily="Geist"
fallbackFontFamily="Helvetica"
fontWeight={500}
fontStyle="normal"
/>
</head>
<Preview>
{`${failedCount} of ${totalCount} automation(s) failed on task "${taskTitle}"`}
</Preview>

<Body className="mx-auto my-auto bg-[#fff] font-sans">
<Container
className="mx-auto my-[40px] max-w-[600px] border-transparent p-[20px] md:border-[#E8E7E1]"
style={{ borderStyle: 'solid', borderWidth: 1 }}
>
<Logo />
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-[#121212]">
Automation Failures
</Heading>

<Text className="text-[14px] leading-[24px] text-[#121212]">
Hello {toName},
</Text>

<Text className="text-[14px] leading-[24px] text-[#121212]">
<strong>{failedCount}</strong> of <strong>{totalCount}</strong> automation(s)
failed on task <strong>"{taskTitle}"</strong> in{' '}
<strong>{organizationName}</strong>.
</Text>

{taskStatusChanged && (
<Text className="text-[14px] leading-[24px] text-[#121212]">
Task status has been changed to <strong>Failed</strong>.
</Text>
)}

<Section className="mt-[32px] mb-[32px] text-center">
<Button
className="rounded-[3px] bg-[#121212] px-[20px] py-[12px] text-center text-[14px] font-semibold text-white no-underline"
href={taskUrl}
>
View Task
</Button>
</Section>

<Text className="text-[14px] leading-[24px] text-[#121212]">
or copy and paste this URL into your browser:{' '}
<a href={taskUrl} className="text-[#121212] underline">
{taskUrl}
</a>
</Text>

<Section className="mt-[30px] mb-[20px]">
<Text className="text-[12px] leading-[20px] text-[#666666]">
Don't want to receive task assignment notifications?{' '}
<Link href={unsubscribeUrl} className="text-[#121212] underline">
Manage your email preferences
</Link>
.
</Text>
</Section>

<br />

<Footer />
</Container>
</Body>
</Tailwind>
</Html>
);
};

export default AutomationFailuresEmail;
147 changes: 147 additions & 0 deletions apps/api/src/tasks/internal-task-notification.controller.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
}
Loading