Skip to content

Commit

Permalink
Delete endpoint for custom projects (#197)
Browse files Browse the repository at this point in the history
  • Loading branch information
catalin-oancea authored Dec 24, 2024
1 parent a7af7df commit 20f75c5
Show file tree
Hide file tree
Showing 7 changed files with 142 additions and 15 deletions.
27 changes: 27 additions & 0 deletions api/src/modules/custom-projects/custom-projects.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,31 @@ export class CustomProjectsController {
},
);
}

@UseGuards(AuthGuard('jwt'), RolesGuard)
@RequiredRoles(ROLES.PARTNER, ROLES.ADMIN)
@TsRestHandler(customProjectContract.deleteCustomProjects)
async deleteCustomProjects(
@GetUser() user: User,
@Body() body: { ids: string[] },
): Promise<any> {
return tsRestHandler(
customProjectContract.deleteCustomProjects,
async () => {
if (
!(await this.customProjects.canUserDeleteProjects(user.id, body.ids))
) {
return {
status: 401,
body: null,
};
}
await this.customProjects.removeMany(body.ids);
return {
status: 200,
body: null,
};
},
);
}
}
12 changes: 12 additions & 0 deletions api/src/modules/custom-projects/custom-projects.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { FetchSpecification } from 'nestjs-base-service';
import { GetActivityTypesDefaults } from '@shared/dtos/custom-projects/get-activity-types-defaults.dto';
import { z } from 'zod';
import { customProjecsQuerySchema } from '@shared/contracts/custom-projects.contract';
import { In } from 'typeorm';

export type CustomProjectFetchSpecificacion = z.infer<
typeof customProjecsQuerySchema
Expand Down Expand Up @@ -143,4 +144,15 @@ export class CustomProjectsService extends AppBaseService<

return query;
}

async canUserDeleteProjects(
userId: string,
projectIds: string[],
): Promise<boolean> {
const customProjects = await this.repo.findBy({ id: In(projectIds) });

return customProjects.every(
(customProject) => customProject.user?.id === userId,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { TestManager } from '../../utils/test-manager';
import { customProjectContract } from '@shared/contracts/custom-projects.contract';
import { User } from '@shared/entities/users/user.entity';
import { HttpStatus } from '@nestjs/common';

describe('Delete Custom projects', () => {
let testManager: TestManager;
let jwtToken: string;
let user: User;

beforeAll(async () => {
testManager = await TestManager.createTestManager();
});

beforeEach(async () => {
({ jwtToken, user } = await testManager.setUpTestUser());
await testManager.ingestCountries();
await testManager.ingestExcel(jwtToken);
});

afterEach(async () => {
await testManager.clearDatabase();
});

afterAll(async () => {
await testManager.close();
});

describe('Delete Custom Projects', () => {
test('An anonymous user should be UNAUTHORIZED to delete a custom project', async () => {
// Given
const customProject = await testManager.mocks().createCustomProject({
user: { id: user.id } as User,
});

// When deleting the custom project
const response = await testManager
.request()
.delete(customProjectContract.deleteCustomProjects.path)
.send({ ids: [customProject.id] });

// Then
expect(response.status).toBe(HttpStatus.UNAUTHORIZED);
expect(response.body.errors).toBeDefined();
});

test.only('An authenticated user should not be able to delete projects that do not belong to them', async () => {
// Given a custom project exists
const customProject = await testManager.mocks().createCustomProject();

// When deleting the custom project
const response = await testManager
.request()
.delete(customProjectContract.deleteCustomProjects.path)
.set('Authorization', `Bearer ${jwtToken}`)
.send({ ids: [customProject.id] });

// Then
expect(response.status).toBe(HttpStatus.UNAUTHORIZED);
});

test('An authenticated user should be able to delete projects', async () => {
// Given a custom project exists
const customProject = await testManager.mocks().createCustomProject({
user: { id: user.id } as User,
});

// When deleting the custom project
const response = await testManager
.request()
.delete(customProjectContract.deleteCustomProjects.path)
.set('Authorization', `Bearer ${jwtToken}`)
.send({ ids: [customProject.id] });

expect(response.status).toBe(HttpStatus.OK);

// Then the project should no longer exist
const getProjectResponse = await testManager
.request()
.get(
`${customProjectContract.getCustomProject.path}/${customProject.id}`,
)
.set('Authorization', `Bearer ${jwtToken}`);
expect(getProjectResponse.status).toBe(HttpStatus.NOT_FOUND);
});
});
});
4 changes: 2 additions & 2 deletions backoffice/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,14 +210,14 @@ const start = async () => {
secure: false,
maxAge: undefined,
},
},
}
);

app.use(admin.options.rootPath, adminRouter);

app.listen(PORT, () => {
console.log(
`AdminJS started on http://localhost:${PORT}${admin.options.rootPath}`,
`AdminJS started on http://localhost:${PORT}${admin.options.rootPath}`
);
});
};
Expand Down
6 changes: 2 additions & 4 deletions client/src/containers/my-projects/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,11 @@ const ActionsDropdown = ({
async (id: string): Promise<boolean> => {
try {
const { status } =
await client.customProjects.deleteCustomProject.mutation({
params: {
id,
},
await client.customProjects.deleteCustomProjects.mutation({
extraHeaders: {
...getAuthHeader(session?.accessToken as string),
},
body: { ids: [id] },
});

return status === 200;
Expand Down
6 changes: 3 additions & 3 deletions shared/contracts/custom-projects.contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,13 @@ export const customProjectContract = contract.router({
},
body: contract.type<CustomProject>(),
},
deleteCustomProject: {
deleteCustomProjects: {
method: "DELETE",
path: "/custom-projects/:id",
path: "/custom-projects",
responses: {
200: contract.type<null>(),
},
body: null,
body: contract.type<{ ids: string[] }>(),
},
});

Expand Down
15 changes: 9 additions & 6 deletions shared/entities/custom-project.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@ export enum CARBON_REVENUES_TO_COVER {
}

export enum PROJECT_SPECIFIC_EMISSION {
ONE_EMISSION_FACTOR = 'One emission factor',
TWO_EMISSION_FACTORS = 'Two emission factors',
ONE_EMISSION_FACTOR = "One emission factor",
TWO_EMISSION_FACTORS = "Two emission factors",
}
export enum PROJECT_EMISSION_FACTORS {
TIER_1 = 'Tier 1 - Global emission factor',
TIER_2 = 'Tier 2 - Country-specific emission factor',
TIER_3 = 'Tier 3 - Project specific emission factor',
TIER_1 = "Tier 1 - Global emission factor",
TIER_2 = "Tier 2 - Country-specific emission factor",
TIER_3 = "Tier 3 - Project specific emission factor",
}

@Entity({ name: "custom_projects" })
Expand All @@ -54,7 +54,10 @@ export class CustomProject {
@Column({ name: "abatement_potential", type: "decimal", nullable: true })
abatementPotential?: number;

@ManyToOne(() => User, (user) => user.customProjects, { onDelete: "CASCADE" })
@ManyToOne(() => User, (user) => user.customProjects, {
onDelete: "CASCADE",
eager: true,
})
@JoinColumn({ name: "user_id" })
user?: User;

Expand Down

0 comments on commit 20f75c5

Please sign in to comment.