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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ User
```

- **Project** — name, description, owned by a User
- **Task** — title, description, status (`todo` | `in_progress` | `done`),
- **Task** — title, description, status (`TODO` | `PENDING` | `DONE`),
priority (`low` | `medium` | `high`), belongs to a Project, optionally
assigned to a User
- **Comment** — content, belongs to a Task, written by a User
Expand All @@ -28,7 +28,7 @@ User
- [PostgreSQL](https://www.postgresql.org/) — database
- [Better Auth](https://www.better-auth.com/) — authentication
(email + password, session-based)
- [Arcjet](https://jsm.dev/nestjs-arcjet) — rate limiting / bot protection
- [Arcjet](https://arcjet.com/) — rate limiting / bot protection / security
- [Biome](https://biomejs.dev/) — linting and formatting
- Husky + commitlint — git hooks, conventional commit enforcement

Expand Down Expand Up @@ -65,7 +65,7 @@ The API will be available at `http://localhost:3000`.

## API documentation

Swagger UI is available at `/api` once the server is running.
Swagger UI is available at `/docs` once the server is running.

## License

Expand Down
9 changes: 5 additions & 4 deletions src/app.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import { Test, TestingModule } from "@nestjs/testing";
import { AppController } from "./app.controller.js";
import { AppService } from "./app.service.js";

describe("AppController", () => {
let appController: AppController;

beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();

appController = app.get<AppController>(AppController);
});

describe("root", () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe("Hello World!");
it("returns welcome message with docs link", () => {
expect(appController.getRoot()).toEqual({
message: "Welcome to the Taskforge API",
docs: "Visit /docs for interactive documentation and endpoint details",
});
});
});
});
12 changes: 7 additions & 5 deletions src/app.controller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Controller, Get } from "@nestjs/common";
import { AppService } from "./app.service.js";
import { AllowAnonymous } from "@thallesp/nestjs-better-auth";

@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}

@Get()
getHello(): string {
return this.appService.getHello();
@AllowAnonymous()
getRoot() {
return {
message: "Welcome to the Taskforge API",
docs: "Visit /docs for interactive documentation and endpoint details",
};
}
}
2 changes: 0 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { AuthModule } from "@thallesp/nestjs-better-auth";
import { AppController } from "./app.controller.js";
import { AppService } from "./app.service.js";
import { createAuth } from "./lib/auth/auth.config.js";
import { envValidationSchema } from "./lib/config/env.config.js";
import { PrismaModule } from "./lib/database/prisma.module.js";
Expand Down Expand Up @@ -33,6 +32,5 @@ import { UserModule } from "./module/user/user.module.js";
ArcjetSecurityModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
8 changes: 0 additions & 8 deletions src/app.service.ts

This file was deleted.

28 changes: 26 additions & 2 deletions src/common/filters/all-exceptions.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
errors: unknown;
};
response.status(status).json({ statusCode: status, message, errors });

return;
}

Expand All @@ -38,20 +39,42 @@ export class AllExceptionsFilter implements ExceptionFilter {

if (exception instanceof Prisma.PrismaClientKnownRequestError) {
if (exception.code === "P2002") {
const field = (
exception.meta as { target?: string[] } | undefined
)?.target?.join(", ");
response.status(HttpStatus.CONFLICT).json({
statusCode: HttpStatus.CONFLICT,
message: "Resource already exists",
message: field
? `Resource with this ${field} already exists`
: "Resource already exists",
data: null,
});

return;
}

if (exception.code === "P2025") {
const cause = (exception.meta as { cause?: string } | undefined)?.cause;
response.status(HttpStatus.NOT_FOUND).json({
statusCode: HttpStatus.NOT_FOUND,
message: "Resource not found",
message: cause ?? "Resource not found",
data: null,
});

return;
}

if (exception.code === "P2003") {
const field = (exception.meta as { field_name?: string } | undefined)
?.field_name;
response.status(HttpStatus.BAD_REQUEST).json({
statusCode: HttpStatus.BAD_REQUEST,
message: field
? `Referenced ${field} does not exist`
: "Foreign key constraint failed",
data: null,
});

return;
}

Expand All @@ -64,6 +87,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
message: "Database error",
data: null,
});

return;
}

Expand Down
9 changes: 5 additions & 4 deletions src/lib/database/prisma.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ export class PrismaService
private readonly logger = new Logger(PrismaService.name);

constructor(configService: ConfigService) {
const pool = new PrismaPg({
connectionString: configService.getOrThrow("DATABASE_URL"),
});
super({ adapter: pool });
const adapter = new PrismaPg(
{ connectionString: configService.getOrThrow("DATABASE_URL") },
{ onPoolError: (err) => this.logger.error("Database pool error", err) },
);
super({ adapter });
}

async onModuleInit() {
Expand Down
1 change: 1 addition & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ async function bootstrap() {
}),
);

app.enableShutdownHooks();
await app.listen(configService.getOrThrow("PORT"));
}
bootstrap();
25 changes: 25 additions & 0 deletions src/module/user/dto/create-user.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
IsEmail,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
} from "class-validator";

export class CreateUserDto {
@ApiProperty()
@IsEmail()
@IsNotEmpty()
email: string;

@ApiPropertyOptional()
@IsString()
@IsOptional()
name?: string;

@ApiPropertyOptional({ enum: ["ADMIN", "MEMBER"] })
@IsIn(["ADMIN", "MEMBER"])
@IsOptional()
role?: "ADMIN" | "MEMBER";
}
4 changes: 4 additions & 0 deletions src/module/user/dto/update-user.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/swagger";
import { CreateUserDto } from "./create-user.dto.js";

export class UpdateUserDto extends PartialType(CreateUserDto) {}
110 changes: 110 additions & 0 deletions src/module/user/user.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { jest } from "@jest/globals";
import { Test, TestingModule } from "@nestjs/testing";
import { UserController } from "./user.controller.js";
import { UserService } from "./user.service.js";

const mockUser = {
id: "user-1",
email: "test@example.com",
name: "Test User",
role: "MEMBER",
emailVerified: false,
image: null,
createdAt: new Date(),
updatedAt: new Date(),
};

describe("UserController", () => {
let controller: UserController;
let service: jest.Mocked<UserService>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UserController],
providers: [
{
provide: UserService,
useValue: {
findAll: jest.fn(),
findById: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
],
}).compile();

controller = module.get<UserController>(UserController);
service = module.get(UserService);
});

afterEach(() => {
jest.clearAllMocks();
});

describe("findAll", () => {
it("calls service.findAll", async () => {
service.findAll.mockResolvedValue([mockUser]);
const result = await controller.findAll();
expect(result).toEqual([mockUser]);
expect(service.findAll).toHaveBeenCalled();
});
});

describe("findById", () => {
it("calls service.findById with the id", async () => {
service.findById.mockResolvedValue(mockUser);
const result = await controller.findById("user-1");
expect(result).toEqual(mockUser);
expect(service.findById).toHaveBeenCalledWith("user-1");
});
});

describe("create", () => {
it("calls service.create with the dto", async () => {
const dto = {
email: "new@example.com",
name: "New User",
role: "MEMBER" as const,
};
service.create.mockResolvedValue({
...mockUser,
email: "new@example.com",
name: "New User",
});

const result = await controller.create(dto);

expect(result).toEqual({
...mockUser,
email: "new@example.com",
name: "New User",
});
expect(service.create).toHaveBeenCalledWith(dto);
});
});

describe("update", () => {
it("calls service.update with id and dto", async () => {
const dto = { name: "Updated" };
service.update.mockResolvedValue({ ...mockUser, name: "Updated" });

const result = await controller.update("user-1", dto);

expect(result).toEqual({ ...mockUser, name: "Updated" });
expect(service.update).toHaveBeenCalledWith("user-1", dto);
});
});

describe("delete", () => {
it("calls service.delete with the id", async () => {
service.delete.mockResolvedValue(undefined);

const result = await controller.delete("user-1");

expect(result).toBeUndefined();
expect(service.delete).toHaveBeenCalledWith("user-1");
});
});
});
41 changes: 40 additions & 1 deletion src/module/user/user.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import { Controller, Get, Param, UseGuards } from "@nestjs/common";
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { ResponseMessage } from "../../common/decorators/response-message.decorator.js";
import { Roles } from "../../common/decorators/roles.decorator.js";
import { RolesGuard } from "../../common/guards/roles.guard.js";
import { CreateUserDto } from "./dto/create-user.dto.js";
import { UpdateUserDto } from "./dto/update-user.dto.js";
import { UserService } from "./user.service.js";

@ApiTags("Users")
Expand All @@ -25,4 +37,31 @@ export class UserController {
findById(@Param("id") id: string) {
return this.userService.findById(id);
}

@Post()
@UseGuards(RolesGuard)
@Roles("ADMIN")
@ApiOperation({ summary: "Create a user with role (admin only)" })
@ResponseMessage("User created successfully")
create(@Body() dto: CreateUserDto) {
return this.userService.create(dto);
}

@Patch(":id")
@UseGuards(RolesGuard)
@Roles("ADMIN")
@ApiOperation({ summary: "Update a user (admin only)" })
@ResponseMessage("User updated successfully")
update(@Param("id") id: string, @Body() dto: UpdateUserDto) {
return this.userService.update(id, dto);
}

@Delete(":id")
@UseGuards(RolesGuard)
@Roles("ADMIN")
@ApiOperation({ summary: "Delete a user (admin only)" })
@ResponseMessage("User deleted successfully")
delete(@Param("id") id: string) {
return this.userService.delete(id);
}
}
Loading
Loading