-
Notifications
You must be signed in to change notification settings - Fork 250
Add internal endpoint for task status change notifications #2166
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+567
−7
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
40a92a8
feat: add internal endpoint for task status change notifications
tofikwest 63bbd1d
fix: add validation decorators to DTO and return 500 on failure
tofikwest 9668d70
chore: update openapi.json with validated DTO schema
tofikwest 4f6fa55
feat: add automation failure notifications for partial failures
tofikwest 318cb35
fix: use Object.values(TaskStatus) instead of hardcoded array
tofikwest File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
147
apps/api/src/tasks/internal-task-notification.controller.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| } | ||
tofikwest marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| @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'); | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.