Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
3 changes: 0 additions & 3 deletions .eslintignore

This file was deleted.

17 changes: 0 additions & 17 deletions .eslintrc.json

This file was deleted.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
# brebaje

Work In Progress
2 changes: 1 addition & 1 deletion apps/backend/.prettierrc
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
}
42 changes: 42 additions & 0 deletions apps/backend/src/auth/guards/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { User } from 'src/users/user.model';

interface JwtPayload {
user: User;
iat: number;
exp: number;
}

@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { user: User }>();
const token = this.extractTokenFromHeader(request);

if (!token) {
throw new UnauthorizedException('No token provided');
}

try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
secret: process.env.JWT_SECRET,
});

// Attach user to request object
request.user = payload.user;
} catch {
throw new UnauthorizedException('Invalid or expired token');
}

return true;
}

private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
1 change: 1 addition & 0 deletions apps/backend/src/database/diagram.dbml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Table projects {
name varchar [not null, note: 'title in the frontend']
contact varchar [not null, note: 'E.g.: nicoserranop (Discord)']
coordinatorId int [ref: > users.id, not null]
createdDate int [not null]
}

Table ceremonies {
Expand Down
19 changes: 15 additions & 4 deletions apps/backend/src/projects/dto/create-project.dto.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, MinLength, MaxLength, IsNumber, IsOptional } from 'class-validator';

export class CreateProjectDto {
@ApiProperty({ example: 'My Project' })
@ApiProperty({ example: 'My ZK Project', description: 'Project name' })
@IsString()
@IsNotEmpty()
@MinLength(3)
@MaxLength(100)
name: string;

@ApiProperty({ example: 'contact@example.com' })
@ApiProperty({ example: 'discord: myusername#1234', description: 'Contact information' })
@IsString()
@IsNotEmpty()
@MinLength(3)
@MaxLength(200)
contact: string;

@ApiProperty({ example: 1 })
coordinatorId: number;
@ApiProperty({ example: 1, description: 'Coordinator user ID', required: false })
@IsNumber()
@IsOptional()
coordinatorId?: number;
}
49 changes: 49 additions & 0 deletions apps/backend/src/projects/guards/project-ownership.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { Request } from 'express';
import { User } from 'src/users/user.model';
import { ProjectsService } from '../projects.service';

@Injectable()
export class ProjectOwnershipGuard implements CanActivate {
constructor(private projectsService: ProjectsService) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { user: User }>();
const user = request.user;
const projectId = parseInt(request.params.id, 10);

if (!user) {
throw new ForbiddenException('User not authenticated');
}

if (!projectId) {
throw new ForbiddenException('Project ID not provided');
}

try {
const project = await this.projectsService.findOne(projectId);

if (project.coordinatorId !== user.id) {
throw new ForbiddenException(
'You do not have permission to perform this action on this project',
);
}

return true;
} catch (error: unknown) {
if (error instanceof NotFoundException) {
throw new NotFoundException('Project not found');
}
if (error instanceof Error && error.message === 'Project not found') {
throw new NotFoundException('Project not found');
}
throw error;
}
}
}
7 changes: 6 additions & 1 deletion apps/backend/src/projects/project.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ export interface ProjectAttributes {
name: string;
contact: string;
coordinatorId: number;
createdAt?: Date;
updatedAt?: Date;
}

export type ProjectPk = 'id';
export type ProjectId = Project[ProjectPk];
export type ProjectOptionalAttributes = 'id';
export type ProjectOptionalAttributes = 'id' | 'createdAt' | 'updatedAt';
export type ProjectCreationAttributes = Optional<ProjectAttributes, ProjectOptionalAttributes>;

@Table({ tableName: 'projects' })
Expand Down Expand Up @@ -45,6 +47,9 @@ export class Project extends Model implements ProjectAttributes {
})
declare coordinatorId: number;

declare createdAt: Date;
declare updatedAt: Date;

@BelongsTo(() => User, 'coordinatorId')
declare user: User;

Expand Down
16 changes: 13 additions & 3 deletions apps/backend/src/projects/projects.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { Test, TestingModule } from '@nestjs/testing';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { User } from 'src/users/user.model';

// Mock the dependencies
jest.mock('./projects.service', () => {
Expand Down Expand Up @@ -32,6 +35,12 @@ describe('ProjectsController', () => {
provide: ProjectsService,
useValue: mockProjectsService,
},
{
provide: JwtService,
useValue: {
verifyAsync: jest.fn(),
},
},
],
}).compile();

Expand All @@ -48,14 +57,15 @@ describe('ProjectsController', () => {
const createProjectDto = {
name: 'New Project',
contact: 'contact@example.com',
coordinatorId: 1,
};
const mockUser = { id: 1, displayName: 'Test User' } as User;
const mockReq = { user: mockUser } as Request & { user: User };
const mockResult = { id: 1, name: 'New Project' };

jest.spyOn(service, 'create').mockImplementation(() => Promise.resolve(mockResult as any));

expect(await controller.create(createProjectDto)).toBe(mockResult);
expect(service.create).toHaveBeenCalledWith(createProjectDto);
expect(await controller.create(createProjectDto, mockReq)).toBe(mockResult);
expect(service.create).toHaveBeenCalledWith(createProjectDto, mockUser);
});
});

Expand Down
36 changes: 32 additions & 4 deletions apps/backend/src/projects/projects.controller.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,45 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBearerAuth } from '@nestjs/swagger';
import { Request as ExpressRequest } from 'express';
import { User } from 'src/users/user.model';
import { Project } from './project.model';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
import { ProjectsService } from './projects.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ProjectOwnershipGuard } from './guards/project-ownership.guard';

@ApiTags('projects')
@Controller('projects')
export class ProjectsController {
constructor(private readonly projectsService: ProjectsService) {}

@Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Create a new project' })
@ApiResponse({
status: 201,
description: 'The project has been successfully created.',
type: Project,
})
@ApiResponse({ status: 400, description: 'Bad Request.' })
create(@Body() createProjectDto: CreateProjectDto) {
return this.projectsService.create(createProjectDto);
@ApiResponse({ status: 401, description: 'Unauthorized.' })
create(
@Body() createProjectDto: CreateProjectDto,
@Request() req: ExpressRequest & { user: User },
) {
return this.projectsService.create(createProjectDto, req.user);
}

@Get()
Expand All @@ -39,6 +59,8 @@ export class ProjectsController {
}

@Patch(':id')
@UseGuards(JwtAuthGuard, ProjectOwnershipGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update a project' })
@ApiParam({ name: 'id', type: 'number' })
@ApiResponse({
Expand All @@ -47,15 +69,21 @@ export class ProjectsController {
type: Project,
})
@ApiResponse({ status: 404, description: 'Project not found.' })
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({ status: 403, description: 'Forbidden - Not the project owner.' })
update(@Param('id') id: string, @Body() updateProjectDto: UpdateProjectDto) {
return this.projectsService.update(+id, updateProjectDto);
}

@Delete(':id')
@UseGuards(JwtAuthGuard, ProjectOwnershipGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Delete a project' })
@ApiParam({ name: 'id', type: 'number' })
@ApiResponse({ status: 200, description: 'The project has been successfully deleted.' })
@ApiResponse({ status: 404, description: 'Project not found.' })
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({ status: 403, description: 'Forbidden - Not the project owner.' })
remove(@Param('id') id: string) {
return this.projectsService.remove(+id);
}
Expand Down
13 changes: 11 additions & 2 deletions apps/backend/src/projects/projects.module.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { JwtModule } from '@nestjs/jwt';
import { Project } from './project.model';
import { ProjectsController } from './projects.controller';
import { ProjectsService } from './projects.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ProjectOwnershipGuard } from './guards/project-ownership.guard';

@Module({
imports: [SequelizeModule.forFeature([Project])],
imports: [
SequelizeModule.forFeature([Project]),
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '1d' },
}),
],
controllers: [ProjectsController],
providers: [ProjectsService],
providers: [ProjectsService, JwtAuthGuard, ProjectOwnershipGuard],
exports: [ProjectsService],
})
export class ProjectsModule {}
25 changes: 18 additions & 7 deletions apps/backend/src/projects/projects.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
import { ProjectsService } from './projects.service';
import { User } from 'src/users/user.model';

// Mock the Project model to avoid import issues
jest.mock('./project.model', () => {
Expand Down Expand Up @@ -33,6 +34,12 @@ describe('ProjectsService', () => {
destroy: jest.fn(),
};

const mockUser = {
id: 1,
displayName: 'Test User',
// Add other User properties if needed by tests
} as User;

beforeEach(async () => {
mockProjectModel = {
create: jest.fn(),
Expand Down Expand Up @@ -63,32 +70,36 @@ describe('ProjectsService', () => {
const createProjectDto: CreateProjectDto = {
name: 'New Project',
contact: 'contact@example.com',
coordinatorId: 1,
};

const expectedProject = {
name: createProjectDto.name,
contact: createProjectDto.contact,
coordinatorId: mockUser.id,
};

mockProjectModel.create.mockResolvedValue({
id: 1,
...createProjectDto,
...expectedProject,
});

const result = await service.create(createProjectDto);
const result = await service.create(createProjectDto, mockUser);

expect(mockProjectModel.create).toHaveBeenCalledWith(createProjectDto);
expect(result).toEqual({ id: 1, ...createProjectDto });
expect(mockProjectModel.create).toHaveBeenCalledWith(expectedProject);
expect(result).toEqual({ id: 1, ...expectedProject });
});

it('should throw a ConflictException when a project with the same name already exists', async () => {
const createProjectDto: CreateProjectDto = {
name: 'Existing Project',
contact: 'contact@example.com',
coordinatorId: 1,
};

const error = new Error('Project already exists');
error.name = 'SequelizeUniqueConstraintError';
mockProjectModel.create.mockRejectedValue(error);

await expect(service.create(createProjectDto)).rejects.toThrow(ConflictException);
await expect(service.create(createProjectDto, mockUser)).rejects.toThrow(ConflictException);
});
});

Expand Down
Loading
Loading