Skip to content

feat: add a delete notifications endpoint #2598

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

Closed
wants to merge 1 commit into from
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { deleteSubscriptionsDtoBuilder } from '@/routes/notifications/v2/entities/__tests__/delete-subscriptions.dto.builder';
import { DeleteSubscriptionsDtoSchema } from '@/domain/notifications/v2/entities/delete-subscriptions.dto.entity';
import { faker } from '@faker-js/faker';
import { getAddress } from 'viem';

describe('DeleteSubscriptionsDtoSchema', () => {
it('should validate a valid DeleteSubscriptionsDto', () => {
const dto = deleteSubscriptionsDtoBuilder().build();

const result = DeleteSubscriptionsDtoSchema.safeParse(dto);

expect(result.success).toBe(true);
});

it('should require safes', () => {
const dto = deleteSubscriptionsDtoBuilder().build();
// @ts-expect-error test missing property
delete dto.safes;

const result = DeleteSubscriptionsDtoSchema.safeParse(dto);

expect(!result.success && result.error.issues).toStrictEqual([
{
code: 'invalid_type',
expected: 'array',
message: 'Required',
path: ['safes'],
received: 'undefined',
},
]);
});

it.each([
['chainId' as const, 'string'],
['address' as const, 'string'],
])('should require safes[number].%s', (key, expected) => {
const dto = deleteSubscriptionsDtoBuilder()
.with('safes', [
{
chainId: faker.string.numeric(),
address: getAddress(faker.finance.ethereumAddress()),
},
])
.build();

delete dto.safes[0][key];

const result = DeleteSubscriptionsDtoSchema.safeParse(dto);

expect(!result.success && result.error.issues).toStrictEqual([
{
code: 'invalid_type',
expected,
message: 'Required',
path: ['safes', 0, key],
received: 'undefined',
},
]);
});

it('should checksum safes[number].address', () => {
const nonChecksum = faker.finance.ethereumAddress().toLowerCase();
const dto = deleteSubscriptionsDtoBuilder()
.with('safes', [
{
chainId: faker.string.numeric(),
address: nonChecksum as `0x${string}`,
},
])
.build();

const result = DeleteSubscriptionsDtoSchema.safeParse(dto);

expect(result.success && result.data.safes[0].address).toBe(
getAddress(nonChecksum),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { AddressSchema } from '@/validation/entities/schemas/address.schema';
import { ApiProperty } from '@nestjs/swagger';
import { z } from 'zod';

export const DeleteSubscriptionsDtoSafesSchema = z.object({
chainId: z.string(),
address: AddressSchema,
});

export const DeleteSubscriptionsDtoSchema = z.object({
safes: z.array(DeleteSubscriptionsDtoSafesSchema),
});

export class DeleteSubscriptionsSafesDto
implements z.infer<typeof DeleteSubscriptionsDtoSafesSchema>
{
@ApiProperty()
chainId!: string;

@ApiProperty()
address!: `0x${string}`;
}

export class DeleteSubscriptionsDto
implements z.infer<typeof DeleteSubscriptionsDtoSchema>
{
@ApiProperty({ type: [DeleteSubscriptionsSafesDto] })
safes!: Array<DeleteSubscriptionsSafesDto>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { faker } from '@faker-js/faker';
import type { IBuilder } from '@/__tests__/builder';
import { Builder } from '@/__tests__/builder';
import { getAddress } from 'viem';
import type { DeleteSubscriptionsDto } from '@/domain/notifications/v2/entities/delete-subscriptions.dto.entity';

export function deleteSubscriptionsDtoBuilder(): IBuilder<DeleteSubscriptionsDto> {
return new Builder<DeleteSubscriptionsDto>().with(
'safes',
Array.from({ length: faker.number.int({ min: 1, max: 5 }) }, () => ({
chainId: faker.string.numeric(),
address: getAddress(faker.finance.ethereumAddress()),
})),
);
}
57 changes: 57 additions & 0 deletions src/routes/notifications/v2/notifications.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder';
import { TestLoggingModule } from '@/logging/__tests__/test.logging.module';
import { RequestScopedLoggingModule } from '@/logging/logging.module';
import { upsertSubscriptionsDtoBuilder } from '@/routes/notifications/v2/entities/__tests__/upsert-subscriptions.dto.builder';
import { deleteSubscriptionsDtoBuilder } from '@/routes/notifications/v2/entities/__tests__/delete-subscriptions.dto.builder';
import type { Chain } from '@/routes/chains/entities/chain.entity';
import { faker } from '@faker-js/faker';
import type { INestApplication } from '@nestjs/common';
Expand Down Expand Up @@ -1001,6 +1002,62 @@ describe('Notifications Controller V2 (Unit)', () => {
});
});

describe('DELETE /v2/notifications/devices/:deviceUuid/safes', () => {
it('should delete the subscriptions for the provided Safes', async () => {
const deviceUuid = faker.string.uuid();
const dto = deleteSubscriptionsDtoBuilder().build();

await request(app.getHttpServer())
.delete(`/v2/notifications/devices/${deviceUuid}/safes`)
.send(dto)
.expect(200);

expect(notificationsRepository.deleteSubscription).toHaveBeenCalledTimes(
dto.safes.length,
);
for (const [index, safe] of dto.safes.entries()) {
expect(
notificationsRepository.deleteSubscription,
).toHaveBeenNthCalledWith(index + 1, {
deviceUuid,
chainId: safe.chainId,
safeAddress: safe.address,
});
}
});

it('should return 422 if the deviceUuid is invalid', async () => {
const invalidDeviceUuid = faker.string.alphanumeric();
const dto = deleteSubscriptionsDtoBuilder().build();

await request(app.getHttpServer())
.delete(`/v2/notifications/devices/${invalidDeviceUuid}/safes`)
.send(dto)
.expect({
statusCode: 422,
validation: 'uuid',
code: 'invalid_string',
message: 'Invalid UUID',
path: [],
});
});

it('should forward datasource errors', async () => {
const deviceUuid = faker.string.uuid();
const dto = deleteSubscriptionsDtoBuilder().build();
const error = new NotFoundException();
notificationsRepository.deleteSubscription.mockRejectedValue(error);

await request(app.getHttpServer())
.delete(`/v2/notifications/devices/${deviceUuid}/safes`)
.send(dto)
.expect({
message: 'Not Found',
statusCode: 404,
});
});
});

describe('DELETE /v2/chains/:chainId/notifications/devices/:deviceUuid', () => {
it('should delete the device', async () => {
const chainId = faker.string.numeric();
Expand Down
32 changes: 31 additions & 1 deletion src/routes/notifications/v2/notifications.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity';
import { UpsertSubscriptionsDto } from '@/routes/notifications/v2/entities/upsert-subscriptions.dto.entity';
import { UpsertSubscriptionsDtoSchema } from '@/domain/notifications/v2/entities/upsert-subscriptions.dto.entity';
import { NotificationsServiceV2 } from '@/routes/notifications/v2/notifications.service';
import {
DeleteSubscriptionsDto,
DeleteSubscriptionsDtoSchema,
} from '@/domain/notifications/v2/entities/delete-subscriptions.dto.entity';
import { Auth } from '@/routes/auth/decorators/auth.decorator';
import { AuthGuard } from '@/routes/auth/guards/auth.guard';
import { AddressSchema } from '@/validation/entities/schemas/address.schema';
Expand All @@ -17,7 +21,7 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ApiTags, ApiBody, ApiOperation, ApiParam } from '@nestjs/swagger';
import { UUID } from 'crypto';
import { OptionalAuthGuard } from '@/routes/auth/guards/optional-auth.guard';
import { NotificationType } from '@/datasources/notifications/entities/notification-type.entity.db';
Expand Down Expand Up @@ -73,6 +77,32 @@ export class NotificationsControllerV2 {
});
}

@Delete('notifications/devices/:deviceUuid/safes')
@ApiOperation({
summary: 'Delete notification subscriptions for multiple safes',
description:
'Deletes notification subscriptions for a list of safes associated with the specified device UUID',
})
@ApiParam({
name: 'deviceUuid',
description: 'UUID of the device to delete subscriptions for',
type: 'string',
})
@ApiBody({ type: DeleteSubscriptionsDto })
deleteSubscriptions(
@Param('deviceUuid', new ValidationPipe(UuidSchema)) deviceUuid: UUID,
@Body(new ValidationPipe(DeleteSubscriptionsDtoSchema))
deleteSubscriptionsDto: DeleteSubscriptionsDto,
): Promise<void> {
return this.notificationsService.deleteSubscriptions({
deviceUuid,
safes: deleteSubscriptionsDto.safes.map((safe) => ({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: For more readability we can extract this map to a const

chainId: safe.chainId,
safeAddress: safe.address,
})),
});
}

@Delete('chains/:chainId/notifications/devices/:deviceUuid')
deleteDevice(
@Param('chainId', new ValidationPipe(NumericStringSchema)) _chainId: string,
Expand Down
13 changes: 13 additions & 0 deletions src/routes/notifications/v2/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ export class NotificationsServiceV2 {
await this.notificationsRepository.deleteSubscription(args);
}

async deleteSubscriptions(args: {
deviceUuid: UUID;
safes: Array<{ chainId: string; safeAddress: `0x${string}` }>;
}): Promise<void> {
for (const safe of args.safes) {
await this.notificationsRepository.deleteSubscription({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest we avoid calling deleteSubscription in a loop and instead create a new repository method with a custom database query. Currently, passing 10 subscriptions results in at least 10 queries—possibly 20, depending on how we aak the ORM to load the device relation. By writing a dedicated method, we can fetch all related devices in a single query and remove all safes for those devices in another, reducing it to just two queries total.

deviceUuid: args.deviceUuid,
chainId: safe.chainId,
safeAddress: safe.safeAddress,
});
}
Comment on lines +45 to +51
Copy link
Preview

Copilot AI May 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using Promise.all to concurrently delete subscriptions for improved performance if the deletion operations are independent.

Suggested change
for (const safe of args.safes) {
await this.notificationsRepository.deleteSubscription({
deviceUuid: args.deviceUuid,
chainId: safe.chainId,
safeAddress: safe.safeAddress,
});
}
await Promise.all(
args.safes.map(safe =>
this.notificationsRepository.deleteSubscription({
deviceUuid: args.deviceUuid,
chainId: safe.chainId,
safeAddress: safe.safeAddress,
}),
),
);

Copilot uses AI. Check for mistakes.

}

async deleteDevice(deviceUuid: UUID): Promise<void> {
await this.notificationsRepository.deleteDevice(deviceUuid);
}
Expand Down
Loading