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
25 changes: 25 additions & 0 deletions apps/api/src/devices/devices.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ describe('DevicesController', () => {
findAllByOrganization: jest.fn(),
findAllByMember: jest.fn(),
getMemberById: jest.fn(),
removeDeviceById: jest.fn(),
};

const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
Expand Down Expand Up @@ -202,4 +203,28 @@ describe('DevicesController', () => {
).rejects.toThrow('FleetDM unavailable');
});
});

describe('deleteDevice', () => {
it('should call service removeDeviceById with org, device, and user', async () => {
mockService.removeDeviceById.mockResolvedValue(undefined);

await controller.deleteDevice('dev_1', 'org_1', mockAuthContext);

expect(service.removeDeviceById).toHaveBeenCalledWith({
organizationId: 'org_1',
deviceId: 'dev_1',
userId: 'usr_1',
});
});

it('should propagate service errors', async () => {
mockService.removeDeviceById.mockRejectedValue(
new Error('Only organization owners can remove devices'),
);

await expect(
controller.deleteDevice('dev_1', 'org_1', mockAuthContext),
).rejects.toThrow('Only organization owners can remove devices');
});
});
});
39 changes: 38 additions & 1 deletion apps/api/src/devices/devices.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { Controller, Delete, Get, HttpCode, Param, UseGuards } from '@nestjs/common';
import {
ApiOperation,
ApiParam,
Expand Down Expand Up @@ -220,4 +220,41 @@ export class DevicesController {
}),
};
}

@Delete(':id')
@RequirePermission('member', 'delete')
@HttpCode(204)
@ApiOperation({
summary: 'Delete device',
description:
'Deletes a single device in the authenticated organization. Only organization owners can delete devices.',
})
@ApiParam({
name: 'id',
description: 'Device ID to delete',
example: 'dev_abc123def456',
})
@ApiResponse({
status: 204,
description: 'Device deleted successfully',
})
@ApiResponse({
status: 403,
description: 'Forbidden - only organization owners can delete devices',
})
@ApiResponse({
status: 404,
description: 'Organization or device not found',
})
async deleteDevice(
@Param('id') id: string,
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
): Promise<void> {
await this.devicesService.removeDeviceById({
organizationId,
deviceId: id,
userId: authContext.userId,
});
}
}
134 changes: 134 additions & 0 deletions apps/api/src/devices/devices.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { db } from '@db';
import { FleetService } from '../lib/fleet.service';
import { DevicesService } from './devices.service';

jest.mock('@db', () => ({
db: {
organization: { findUnique: jest.fn() },
member: { findFirst: jest.fn() },
device: { deleteMany: jest.fn() },
},
}));

describe('DevicesService', () => {
let service: DevicesService;

const mockFleetService = {
getHostsByLabel: jest.fn(),
getMultipleHosts: jest.fn(),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DevicesService,
{ provide: FleetService, useValue: mockFleetService },
],
}).compile();

service = module.get<DevicesService>(DevicesService);
jest.clearAllMocks();
});

describe('removeDeviceById', () => {
it('throws when organization does not exist', async () => {
(db.organization.findUnique as jest.Mock).mockResolvedValue(null);

await expect(
service.removeDeviceById({
organizationId: 'org_missing',
deviceId: 'dev_1',
userId: 'usr_1',
}),
).rejects.toThrow(NotFoundException);
});

it('throws when user id is missing', async () => {
(db.organization.findUnique as jest.Mock).mockResolvedValue({
id: 'org_1',
});

await expect(
service.removeDeviceById({
organizationId: 'org_1',
deviceId: 'dev_1',
}),
).rejects.toThrow(ForbiddenException);
});

it('throws when user is not a member of organization', async () => {
(db.organization.findUnique as jest.Mock).mockResolvedValue({
id: 'org_1',
});
(db.member.findFirst as jest.Mock).mockResolvedValue(null);

await expect(
service.removeDeviceById({
organizationId: 'org_1',
deviceId: 'dev_1',
userId: 'usr_1',
}),
).rejects.toThrow('User is not a member of this organization');
});

it('throws when member is not an owner', async () => {
(db.organization.findUnique as jest.Mock).mockResolvedValue({
id: 'org_1',
});
(db.member.findFirst as jest.Mock).mockResolvedValue({
role: 'admin',
});

await expect(
service.removeDeviceById({
organizationId: 'org_1',
deviceId: 'dev_1',
userId: 'usr_1',
}),
).rejects.toThrow('Only organization owners can remove devices');
});

it('throws when device does not exist in organization', async () => {
(db.organization.findUnique as jest.Mock).mockResolvedValue({
id: 'org_1',
});
(db.member.findFirst as jest.Mock).mockResolvedValue({
role: 'owner',
});
(db.device.deleteMany as jest.Mock).mockResolvedValue({ count: 0 });

await expect(
service.removeDeviceById({
organizationId: 'org_1',
deviceId: 'dev_missing',
userId: 'usr_1',
}),
).rejects.toThrow(NotFoundException);
});

it('deletes device when caller is owner', async () => {
(db.organization.findUnique as jest.Mock).mockResolvedValue({
id: 'org_1',
});
(db.member.findFirst as jest.Mock).mockResolvedValue({
role: ' employee , owner ',
});
(db.device.deleteMany as jest.Mock).mockResolvedValue({ count: 1 });

await service.removeDeviceById({
organizationId: 'org_1',
deviceId: 'dev_1',
userId: 'usr_1',
});

expect(db.device.deleteMany).toHaveBeenCalledWith({
where: {
id: 'dev_1',
organizationId: 'org_1',
},
});
});
});
});
67 changes: 66 additions & 1 deletion apps/api/src/devices/devices.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import {
Injectable,
NotFoundException,
Logger,
ForbiddenException,
} from '@nestjs/common';
import { db } from '@db';
import { getDeviceComplianceStatus } from '@trycompai/utils/devices';
import { FleetService } from '../lib/fleet.service';
Expand Down Expand Up @@ -175,6 +180,66 @@ export class DevicesService {
}
}

async removeDeviceById({
organizationId,
deviceId,
userId,
}: {
organizationId: string;
deviceId: string;
userId?: string;
}): Promise<void> {
const organization = await db.organization.findUnique({
where: { id: organizationId },
select: { id: true },
});

if (!organization) {
throw new NotFoundException(
`Organization with ID ${organizationId} not found`,
);
}

if (!userId) {
throw new ForbiddenException('Only organization owners can remove devices');
}

const member = await db.member.findFirst({
where: {
userId,
organizationId,
deactivated: false,
},
select: { role: true },
});

if (!member) {
throw new ForbiddenException('User is not a member of this organization');
}

const memberRoles = member.role
.split(',')
.map((role) => role.trim().toLowerCase());
const isOwner = memberRoles.includes('owner');

if (!isOwner) {
throw new ForbiddenException('Only organization owners can remove devices');
}

const deleteResult = await db.device.deleteMany({
where: {
id: deviceId,
organizationId,
},
});

if (deleteResult.count === 0) {
throw new NotFoundException(
`Device with ID ${deviceId} not found in organization ${organizationId}`,
);
}
}

// --- Private helpers ---

private async getFleetDevicesForOrg(
Expand Down
45 changes: 45 additions & 0 deletions apps/api/src/email/dto/send-batch-email.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
IsString,
IsEmail,
IsOptional,
IsArray,
ValidateNested,
ArrayMinSize,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

class BatchEmailItemDto {
@ApiProperty({ description: 'Recipient email address' })
@IsEmail()
to: string;

@ApiProperty({ description: 'Email subject line' })
@IsString()
subject: string;

@ApiProperty({ description: 'Pre-rendered HTML content' })
@IsString()
html: string;

@ApiPropertyOptional({ description: 'Explicit FROM address override' })
@IsOptional()
@IsString()
from?: string;

@ApiPropertyOptional({ description: 'CC recipients' })
@IsOptional()
cc?: string | string[];
}

export class SendBatchEmailDto {
@ApiProperty({
description: 'Array of emails to send',
type: [BatchEmailItemDto],
})
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => BatchEmailItemDto)
emails: BatchEmailItemDto[];
}
29 changes: 29 additions & 0 deletions apps/api/src/email/email.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
import { PermissionGuard } from '../auth/permission.guard';
import { RequirePermission } from '../auth/require-permission.decorator';
import { SendEmailDto } from './dto/send-email.dto';
import { SendBatchEmailDto } from './dto/send-batch-email.dto';
import type { sendEmailTask } from '../trigger/email/send-email';
import type { sendBatchEmailTask } from '../trigger/email/send-batch-email';

@ApiExcludeController()
@ApiTags('Internal - Email')
Expand Down Expand Up @@ -43,4 +45,31 @@ export class EmailController {

return { success: true, taskId: handle.id };
}

@Post('send-batch')
@HttpCode(200)
@RequirePermission('email', 'send')
@ApiOperation({
summary: 'Send a batch of emails via the centralized Trigger task (internal)',
})
@ApiResponse({ status: 200, description: 'Batch email task triggered' })
async sendBatchEmail(@Body() dto: SendBatchEmailDto) {
const fromAddress =
process.env.RESEND_FROM_SYSTEM ?? process.env.RESEND_FROM_DEFAULT;

const emails = dto.emails.map((email) => ({
to: email.to,
subject: email.subject,
html: email.html,
from: email.from ?? fromAddress,
cc: email.cc,
}));

const handle = await tasks.trigger<typeof sendBatchEmailTask>(
'send-batch-email',
{ emails },
);

return { success: true, taskId: handle.id };
}
}
Loading
Loading