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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
GOOGLE_CALLBACK_URL="http://localhost:4000/auth/google/callback"

# Web app (Next.js admin UI)
CORTEX_WEB_URL="http://localhost:3000"
CORTEX_API_URL="http://localhost:4000"

# JWT
# Use a long, random secret in production: `openssl rand -base64 64`
JWT_SECRET="changeme-dev-secret"
16 changes: 16 additions & 0 deletions apps/api/src/__mocks__/db-client.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,41 @@
export const mockPrismaClient = {
$connect: jest.fn().mockResolvedValue(undefined),
$disconnect: jest.fn().mockResolvedValue(undefined),
$transaction: jest.fn(),
user: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
upsert: jest.fn(),
},
organization: {
findUnique: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
department: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
},
userDepartment: {
findFirst: jest.fn(),
findMany: jest.fn(),
updateMany: jest.fn(),
deleteMany: jest.fn(),
upsert: jest.fn(),
},
};

export class PrismaClient {
$connect = mockPrismaClient.$connect;
$disconnect = mockPrismaClient.$disconnect;
$transaction = mockPrismaClient.$transaction;
user = mockPrismaClient.user;
organization = mockPrismaClient.organization;
department = mockPrismaClient.department;
userDepartment = mockPrismaClient.userDepartment;
}

Expand Down
20 changes: 18 additions & 2 deletions apps/api/src/admin/admin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,26 @@ import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module";
import { OrganizationsController } from "./organizations/organizations.controller";
import { OrganizationService } from "./organizations/organization.service";
import { DepartmentsController } from "./departments/departments.controller";
import { DepartmentService } from "./departments/department.service";
import { AdminUsersController } from "./users/admin-users.controller";
import { AdminUserService } from "./users/admin-user.service";
import { UserDepartmentsController } from "./user-departments/user-departments.controller";
import { UserDepartmentService } from "./user-departments/user-department.service";

@Module({
imports: [PrismaModule],
controllers: [OrganizationsController],
providers: [OrganizationService],
controllers: [
OrganizationsController,
DepartmentsController,
AdminUsersController,
UserDepartmentsController,
],
providers: [
OrganizationService,
DepartmentService,
AdminUserService,
UserDepartmentService,
],
})
export class AdminModule {}
11 changes: 11 additions & 0 deletions apps/api/src/admin/departments/department.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface CreateDepartmentDto {
name: string;
}

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

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

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

const mockDept = {
id: "dept-1",
name: "Engineering",
organizationId: "org-1",
createdAt: now,
updatedAt: now,
};

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

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

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

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

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

describe("findAllByOrganization", () => {
it("returns departments scoped to the organization, ordered by createdAt desc", async () => {
mockPrisma.organization.findUnique.mockResolvedValue(mockOrg);
mockPrisma.department.findMany.mockResolvedValue([mockDept]);

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

expect(result).toEqual([mockDept]);
expect(mockPrisma.department.findMany).toHaveBeenCalledWith({
where: { organizationId: "org-1" },
orderBy: { createdAt: "desc" },
});
});

it("returns an empty array when the organization has no departments", async () => {
mockPrisma.organization.findUnique.mockResolvedValue(mockOrg);
mockPrisma.department.findMany.mockResolvedValue([]);

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

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

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

await expect(
service.findAllByOrganization("missing-org"),
).rejects.toThrow(NotFoundException);
expect(mockPrisma.department.findMany).not.toHaveBeenCalled();
});
});

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

const result = await service.create("org-1", { name: "Engineering" });

expect(result).toEqual(mockDept);
expect(mockPrisma.department.create).toHaveBeenCalledWith({
data: { name: "Engineering", organizationId: "org-1" },
});
});

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

await expect(
service.create("missing-org", { name: "Engineering" }),
).rejects.toThrow(NotFoundException);
expect(mockPrisma.department.create).not.toHaveBeenCalled();
});

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

await expect(
service.create("org-1", { name: "Engineering" }),
).rejects.toThrow(ConflictException);
});

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

await expect(
service.create("org-1", { name: "Engineering" }),
).rejects.toThrow("Connection refused");
});
});
});
60 changes: 60 additions & 0 deletions apps/api/src/admin/departments/department.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
Injectable,
ConflictException,
NotFoundException,
} from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
import type { Department } from "db/client";
import type { CreateDepartmentDto } from "./department.dto";
import { isPrismaUniqueConstraintError } from "../../common/prisma-errors";

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

/**
* Lists departments scoped to a single Organization.
* Throws NotFoundException up front so callers get a clear 404 instead
* of a silently empty list when the organizationId is bogus.
*/
async findAllByOrganization(organizationId: string): Promise<Department[]> {
await this.ensureOrganizationExists(organizationId);
return this.prisma.department.findMany({
where: { organizationId },
orderBy: { createdAt: "desc" },
});
}

async create(
organizationId: string,
dto: CreateDepartmentDto,
): Promise<Department> {
await this.ensureOrganizationExists(organizationId);

try {
return await this.prisma.department.create({
data: { name: dto.name, organizationId },
});
} catch (err) {
if (isPrismaUniqueConstraintError(err)) {
throw new ConflictException(
`Department with name "${dto.name}" already exists in this organization`,
);
}
throw err;
}
}

private async ensureOrganizationExists(
organizationId: string,
): Promise<void> {
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
});
if (!org) {
throw new NotFoundException(
`Organization with id "${organizationId}" not found`,
);
}
}
}
70 changes: 70 additions & 0 deletions apps/api/src/admin/departments/departments.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {
Controller,
Get,
Post,
Param,
Body,
Req,
UseGuards,
HttpCode,
HttpStatus,
BadRequestException,
} from "@nestjs/common";
import { AdminRoleGuard } from "../guards/admin-role.guard";
import { assertAdminOrganizationAccess } from "../guards/assert-admin-organization";
import { DepartmentService } from "./department.service";
import type {
CreateDepartmentDto,
DepartmentResponseDto,
} from "./department.dto";
import type { Department } from "db/client";
import type { AuthenticatedUser } from "../../auth/auth.types";

interface RequestWithUser {
user: AuthenticatedUser;
}

function toResponseDto(dept: Department): DepartmentResponseDto {
return {
id: dept.id,
name: dept.name,
organizationId: dept.organizationId,
createdAt: dept.createdAt.toISOString(),
updatedAt: dept.updatedAt.toISOString(),
};
}

@Controller("api/admin/organizations/:organizationId/departments")
@UseGuards(AdminRoleGuard)
export class DepartmentsController {
constructor(private readonly departmentService: DepartmentService) {}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@Get()
@HttpCode(HttpStatus.OK)
async findAll(
@Req() req: RequestWithUser,
@Param("organizationId") organizationId: string,
): Promise<DepartmentResponseDto[]> {
assertAdminOrganizationAccess(req.user.organizationId, organizationId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fixed: DepartmentsController now calls assertAdminOrganizationAccess(req.user.organizationId, organizationId) before list/create operations. Added integration coverage for cross-org requests (403).

const departments =
await this.departmentService.findAllByOrganization(organizationId);
return departments.map(toResponseDto);
}

@Post()
@HttpCode(HttpStatus.CREATED)
async create(
@Req() req: RequestWithUser,
@Param("organizationId") organizationId: string,
@Body() body: CreateDepartmentDto,
): Promise<DepartmentResponseDto> {
assertAdminOrganizationAccess(req.user.organizationId, organizationId);
if (!body.name || typeof body.name !== "string" || !body.name.trim()) {
throw new BadRequestException("name is required and must be a string");
}
const dept = await this.departmentService.create(organizationId, {
name: body.name.trim(),
});
return toResponseDto(dept);
}
}
Loading
Loading