Skip to content
Open
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
11 changes: 11 additions & 0 deletions apps/api/src/admin/admin.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module";
import { OrganizationsController } from "./organizations/organizations.controller";
import { OrganizationService } from "./organizations/organization.service";

@Module({
imports: [PrismaModule],
controllers: [OrganizationsController],
providers: [OrganizationService],
})
export class AdminModule {}
36 changes: 36 additions & 0 deletions apps/api/src/admin/guards/admin-role.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {
Injectable,
ExecutionContext,
ForbiddenException,
} from "@nestjs/common";
import { JwtAuthGuard } from "../../auth/guards/jwt-auth.guard";
import type { AuthenticatedUser } from "../../auth/auth.types";

interface RequestWithUser {
user: AuthenticatedUser;
}

/**
* Combines JWT authentication with an admin role check.
* Requests from non-admin users receive 403 Forbidden after
* the JWT is validated, not 401 – the user is authenticated
* but not authorized.
*/
@Injectable()
export class AdminRoleGuard extends JwtAuthGuard {
override async canActivate(context: ExecutionContext): Promise<boolean> {
await super.canActivate(context);

const request = context
.switchToHttp()
.getRequest<RequestWithUser>();

if (request.user.role !== "admin") {
throw new ForbiddenException(
"Admin role required to access this resource",
);
}

return true;
}
}
14 changes: 14 additions & 0 deletions apps/api/src/admin/organizations/organization.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export interface CreateOrganizationDto {
name: string;
}

export interface UpdateOrganizationDto {
name?: string;
}

export interface OrganizationResponseDto {
id: string;
name: string;
createdAt: string;
updatedAt: string;
}
195 changes: 195 additions & 0 deletions apps/api/src/admin/organizations/organization.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { Test, type TestingModule } from "@nestjs/testing";
import { ConflictException, NotFoundException } from "@nestjs/common";
import { OrganizationService } from "./organization.service";
import { PrismaService } from "../../prisma/prisma.service";

const now = new Date("2024-01-01T00:00:00Z");

const mockOrg = {
id: "org-1",
name: "acme.com",
createdAt: now,
updatedAt: now,
};

const mockPrisma = {
organization: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
};

describe("OrganizationService", () => {
let service: OrganizationService;

beforeEach(async () => {
jest.clearAllMocks();

const module: TestingModule = await Test.createTestingModule({
providers: [
OrganizationService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();

service = module.get<OrganizationService>(OrganizationService);
});

describe("findAll", () => {
it("returns all organizations ordered by createdAt desc", async () => {
mockPrisma.organization.findMany.mockResolvedValue([mockOrg]);

const result = await service.findAll();

expect(result).toEqual([mockOrg]);
expect(mockPrisma.organization.findMany).toHaveBeenCalledWith({
orderBy: { createdAt: "desc" },
});
});

it("returns an empty array when no organizations exist", async () => {
mockPrisma.organization.findMany.mockResolvedValue([]);

const result = await service.findAll();

expect(result).toEqual([]);
});
});

describe("findOne", () => {
it("returns the organization when found", async () => {
mockPrisma.organization.findUnique.mockResolvedValue(mockOrg);

const result = await service.findOne("org-1");

expect(result).toEqual(mockOrg);
expect(mockPrisma.organization.findUnique).toHaveBeenCalledWith({
where: { id: "org-1" },
});
});

it("throws NotFoundException when organization does not exist", async () => {
mockPrisma.organization.findUnique.mockResolvedValue(null);

await expect(service.findOne("missing-id")).rejects.toThrow(
NotFoundException,
);
});

it("includes the id in the NotFoundException message", async () => {
mockPrisma.organization.findUnique.mockResolvedValue(null);

await expect(service.findOne("missing-id")).rejects.toThrow(
'Organization with id "missing-id" not found',
);
});
});

describe("create", () => {
it("creates and returns the organization", async () => {
mockPrisma.organization.create.mockResolvedValue(mockOrg);

const result = await service.create({ name: "acme.com" });

expect(result).toEqual(mockOrg);
expect(mockPrisma.organization.create).toHaveBeenCalledWith({
data: { name: "acme.com" },
});
});

it("throws ConflictException on P2002 unique constraint violation", async () => {
const p2002 = Object.assign(new Error("Unique constraint failed"), {
code: "P2002",
});
mockPrisma.organization.create.mockRejectedValue(p2002);

await expect(service.create({ name: "acme.com" })).rejects.toThrow(
ConflictException,
);
});

it("ConflictException message includes the duplicate name", async () => {
const p2002 = Object.assign(new Error("Unique constraint failed"), {
code: "P2002",
});
mockPrisma.organization.create.mockRejectedValue(p2002);

await expect(service.create({ name: "acme.com" })).rejects.toThrow(
'Organization with name "acme.com" already exists',
);
});

it("re-throws non-P2002 database errors", async () => {
const dbError = Object.assign(new Error("Connection refused"), {
code: "P1001",
});
mockPrisma.organization.create.mockRejectedValue(dbError);

await expect(service.create({ name: "acme.com" })).rejects.toThrow(
"Connection refused",
);
});
});

describe("update", () => {
it("updates and returns the organization", async () => {
const updated = { ...mockOrg, name: "newname.com" };
mockPrisma.organization.findUnique.mockResolvedValue(mockOrg);
mockPrisma.organization.update.mockResolvedValue(updated);

const result = await service.update("org-1", { name: "newname.com" });

expect(result).toEqual(updated);
expect(mockPrisma.organization.update).toHaveBeenCalledWith({
where: { id: "org-1" },
data: { name: "newname.com" },
});
});

it("throws NotFoundException when the organization does not exist", async () => {
mockPrisma.organization.findUnique.mockResolvedValue(null);

await expect(
service.update("missing-id", { name: "new" }),
).rejects.toThrow(NotFoundException);
});

it("throws ConflictException on P2002 during update", async () => {
const p2002 = Object.assign(new Error("Unique constraint failed"), {
code: "P2002",
});
mockPrisma.organization.findUnique.mockResolvedValue(mockOrg);
mockPrisma.organization.update.mockRejectedValue(p2002);

await expect(
service.update("org-1", { name: "taken.com" }),
).rejects.toThrow(ConflictException);
});

it("throws NotFoundException on P2025 concurrent-delete race during update", async () => {
const p2025 = Object.assign(new Error("Record not found"), {
code: "P2025",
});
mockPrisma.organization.findUnique.mockResolvedValue(mockOrg);
mockPrisma.organization.update.mockRejectedValue(p2025);

await expect(service.update("org-1", { name: "new.com" })).rejects.toThrow(
NotFoundException,
);
});

it("applies no data update when dto is empty", async () => {
mockPrisma.organization.findUnique.mockResolvedValue(mockOrg);
mockPrisma.organization.update.mockResolvedValue(mockOrg);

await service.update("org-1", {});

expect(mockPrisma.organization.update).toHaveBeenCalledWith({
where: { id: "org-1" },
data: {},
});
});
});
});
84 changes: 84 additions & 0 deletions apps/api/src/admin/organizations/organization.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {
Injectable,
ConflictException,
NotFoundException,
} from "@nestjs/common";

function isPrismaNotFoundError(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: string }).code === "P2025"
);
}
import { PrismaService } from "../../prisma/prisma.service";
import type { Organization } from "db/client";
import type {
CreateOrganizationDto,
UpdateOrganizationDto,
} from "./organization.dto";

function isPrismaUniqueConstraintError(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: string }).code === "P2002"
);
}

@Injectable()
export class OrganizationService {
constructor(private readonly prisma: PrismaService) {}

async findAll(): Promise<Organization[]> {
return this.prisma.organization.findMany({
orderBy: { createdAt: "desc" },
});
}

async findOne(id: string): Promise<Organization> {
const org = await this.prisma.organization.findUnique({ where: { id } });
if (!org) {
throw new NotFoundException(`Organization with id "${id}" not found`);
}
return org;
}

async create(dto: CreateOrganizationDto): Promise<Organization> {
try {
return await this.prisma.organization.create({
data: { name: dto.name },
});
} catch (err) {
if (isPrismaUniqueConstraintError(err)) {
throw new ConflictException(
`Organization with name "${dto.name}" already exists`,
);
}
throw err;
}
}

async update(id: string, dto: UpdateOrganizationDto): Promise<Organization> {
await this.findOne(id);

try {
return await this.prisma.organization.update({
where: { id },
data: { ...(dto.name !== undefined && { name: dto.name }) },
});
} catch (err) {
if (isPrismaUniqueConstraintError(err)) {
throw new ConflictException(
`Organization with name "${dto.name}" already exists`,
);
}
if (isPrismaNotFoundError(err)) {
throw new NotFoundException(`Organization with id "${id}" not found`);
}
throw err;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading
Loading