diff --git a/.gitignore b/.gitignore index 5b9add5..399eadd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env node_modules dist -openapi.json \ No newline at end of file +openapi.json +/.claude \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index 750766f..9f208d5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,7 +5,6 @@ import cors from "cors"; import { httpLogger } from "@config/logger"; import v1Router from "./routes/v1.route"; import errorHandler from "./middlewares/errorHandler"; -import { notFound } from "./middlewares/notFound"; import cookieParser from "cookie-parser"; import { COOKIE_SECRET, FRONTEND_BASE_URL, NODE_ENV } from "@config/env"; import swaggerUi from "swagger-ui-express"; @@ -41,19 +40,19 @@ const authLimiter = rateLimit({ app.set("trust proxy", 1); app.use(helmet()); -app.use(limiter); app.use( cors({ origin: (origin, callback) => { if (!origin || origin === FRONTEND_BASE_URL) { callback(null, true); } else { - callback(new Error("Not allowed by CORS")); + callback(null, false); } }, credentials: true, }), ); +app.use(limiter); app.use(cookieParser(COOKIE_SECRET)); app.use(express.json({ limit: "100kb" })); app.use(httpLogger); @@ -77,7 +76,6 @@ app.get("/api/health", (req, res) => { res.send({ status: "ok" }); }); -app.use(notFound); app.use((err: Error, req: Request, res: Response, next: NextFunction) => errorHandler(err, req, res, next), ); diff --git a/src/middlewares/notFound.ts b/src/middlewares/notFound.ts index 5da7b13..6909afc 100644 --- a/src/middlewares/notFound.ts +++ b/src/middlewares/notFound.ts @@ -1,6 +1,7 @@ import { Request, Response } from "express"; -export function notFound(req: Request, res: Response) { +export function notFound(_req: Request, res: Response) { + if (res.headersSent) return; res.status(404).json({ success: false, message: "Resource not found", diff --git a/src/modules/organization/__tests__/integration/updateOrganization.v1.test.ts b/src/modules/organization/__tests__/integration/updateOrganization.v1.test.ts new file mode 100644 index 0000000..ce9cf64 --- /dev/null +++ b/src/modules/organization/__tests__/integration/updateOrganization.v1.test.ts @@ -0,0 +1,355 @@ +import request from "supertest"; +import app from "@app"; +import { clearDB } from "@tests/utils"; +import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; +import UserService from "@modules/user/user.service"; +import OrganizationService from "@modules/organization/organization.service"; +import { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "@modules/auth/__tests__/helpers/testHelpers"; +import { UserFactory } from "@tests/factories/user.factory"; +import { IUser } from "@modules/user/user.types"; +import { seedOneUserWithOrg, seedUserInOrg } from "@tests/helpers/seed"; + +const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; + +beforeEach(async () => { + await clearDB(); +}); + +describe("PUT /api/v1/org", () => { + describe("Authentication", () => { + it("should return 401 if access token cookie is missing", async () => { + const res = await request(app) + .put("/api/v1/org") + .send({ name: "Updated Org" }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("Authentication required"); + }); + + it("should return 401 if access token is invalid", async () => { + const res = await request(app) + .put("/api/v1/org") + .set("Cookie", ["access_token=invalid_token"]) + .send({ name: "Updated Org" }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("should return 401 if user in token does not exist", async () => { + const accessToken = generateAccessToken({ + id: "507f1f77bcf86cd799439011", + email: "nonexistent@example.com", + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .put("/api/v1/org") + .set("Cookie", [cookie]) + .send({ name: "Updated Org" }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("User not found"); + }); + }); + + describe("Authorization", () => { + it("should return 404 if user has no organization", async () => { + const userData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + const user = await UserService.createUser({ + firstName: userData.firstName, + lastName: userData.lastName, + email: userData.email, + password: userData.password, + }); + user.isEmailVerified = true; + await user.save(); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .put("/api/v1/org") + .set("Cookie", [cookie]) + .send({ name: "Updated Org" }); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + }); + + it("should return 403 for a MEMBER role", async () => { + const { organization } = await seedOneUserWithOrg(); + const { user: member } = await seedUserInOrg( + organization._id.toString(), + {}, + "MEMBER", + ); + + const accessToken = generateAccessToken({ + id: member._id.toString(), + email: member.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .put("/api/v1/org") + .set("Cookie", [cookie]) + .send({ name: "Updated Org" }); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + }); + + it("should return 403 for a VIEWER role", async () => { + const { organization } = await seedOneUserWithOrg(); + const { user: viewer } = await seedUserInOrg( + organization._id.toString(), + {}, + "VIEWER", + ); + + const accessToken = generateAccessToken({ + id: viewer._id.toString(), + email: viewer.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .put("/api/v1/org") + .set("Cookie", [cookie]) + .send({ name: "Updated Org" }); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + }); + }); + + describe("Validation", () => { + let cookie: string; + + beforeEach(async () => { + const { user } = await seedOneUserWithOrg({ email: verifiedUserEmail }); + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 400 if name is too short", async () => { + const res = await request(app) + .put("/api/v1/org") + .set("Cookie", [cookie]) + .send({ name: "A" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect( + JSON.stringify(res.body) + .toLowerCase() + .includes("at least 2 characters"), + ).toBe(true); + }); + + it("should return 400 if name is too long", async () => { + const res = await request(app) + .put("/api/v1/org") + .set("Cookie", [cookie]) + .send({ name: "A".repeat(101) }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect( + JSON.stringify(res.body) + .toLowerCase() + .includes("must not exceed 100 characters"), + ).toBe(true); + }); + + it("should return 400 if size is less than 1", async () => { + const res = await request(app) + .put("/api/v1/org") + .set("Cookie", [cookie]) + .send({ size: 0 }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect( + JSON.stringify(res.body).toLowerCase().includes("at least 1"), + ).toBe(true); + }); + + it("should return 400 if size is not an integer", async () => { + const res = await request(app) + .put("/api/v1/org") + .set("Cookie", [cookie]) + .send({ size: 5.5 }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if status is invalid", async () => { + const res = await request(app) + .put("/api/v1/org") + .set("Cookie", [cookie]) + .send({ status: "UNKNOWN" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if description exceeds 500 characters", async () => { + const res = await request(app) + .put("/api/v1/org") + .set("Cookie", [cookie]) + .send({ description: "A".repeat(501) }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect( + JSON.stringify(res.body) + .toLowerCase() + .includes("must not exceed 500 characters"), + ).toBe(true); + }); + }); + + describe("Successful Update — OWNER", () => { + let user: IUser; + let cookie: string; + let orgId: string; + + beforeEach(async () => { + const seeded = await seedOneUserWithOrg( + { email: verifiedUserEmail }, + { + name: "Original Org", + size: 10, + domain: "original.com", + description: "Original description", + }, + "OWNER", + ); + user = seeded.user; + orgId = seeded.organization._id.toString(); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 200 with updated organization", async () => { + const res = await request(app) + .put("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Updated Org Name", + domain: "updated.com", + description: "Updated description", + status: "ACTIVE", + size: 50, + settings: { timezone: "America/New_York", workHours: 9 }, + }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toBe("Organization updated successfully"); + expect(res.body.data).toHaveProperty("organization"); + }); + + it("should persist all fields to the database", async () => { + await request(app) + .put("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Updated Org Name", + domain: "updated.com", + description: "Updated description", + status: "INACTIVE", + size: 50, + settings: { timezone: "America/New_York", workHours: 9 }, + }); + + const org = await OrganizationService.getOrganizationById(orgId); + + expect(org?.name).toBe("Updated Org Name"); + expect(org?.domain).toBe("updated.com"); + expect(org?.description).toBe("Updated description"); + expect(org?.status).toBe("INACTIVE"); + expect(org?.size).toBe(50); + expect(org?.settings.timezone).toBe("America/New_York"); + expect(org?.settings.workHours).toBe(9); + }); + + it("should return the correct response structure", async () => { + const res = await request(app) + .put("/api/v1/org") + .set("Cookie", [cookie]) + .send({ name: "Updated Org Name", size: 20 }); + + const { organization } = res.body.data; + + expect(organization).toHaveProperty("id"); + expect(organization).toHaveProperty("name", "Updated Org Name"); + expect(organization).toHaveProperty("slug"); + expect(organization).toHaveProperty("status"); + expect(organization).toHaveProperty("size", 20); + expect(organization).toHaveProperty("settings"); + expect(organization).toHaveProperty("createdAt"); + expect(organization).toHaveProperty("updatedAt"); + }); + + it("should apply a partial update without affecting unspecified fields", async () => { + await request(app) + .put("/api/v1/org") + .set("Cookie", [cookie]) + .send({ size: 99 }); + + const org = await OrganizationService.getOrganizationById(orgId); + + expect(org?.size).toBe(99); + expect(org?.name).toBe("Original Org"); + expect(org?.domain).toBe("original.com"); + }); + }); + + describe("Successful Update — MANAGER", () => { + it("should allow a MANAGER to update the organization", async () => { + const { organization } = await seedOneUserWithOrg(); + const { user: manager } = await seedUserInOrg( + organization._id.toString(), + {}, + "MANAGER", + ); + + const accessToken = generateAccessToken({ + id: manager._id.toString(), + email: manager.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .put("/api/v1/org") + .set("Cookie", [cookie]) + .send({ name: "Manager Updated Name", size: 25 }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.organization.name).toBe("Manager Updated Name"); + }); + }); +}); diff --git a/src/modules/organization/organization.controller.ts b/src/modules/organization/organization.controller.ts index 1183173..32a7cf5 100644 --- a/src/modules/organization/organization.controller.ts +++ b/src/modules/organization/organization.controller.ts @@ -10,6 +10,9 @@ import { GetOrganizationMembersOutput, InviteMemberOutput, GetUserOrganizationOutput, + UpdateOrganizationInput, + UpdateOrganizationOutput, + IOrganization, } from "./organization.types"; import AppError from "@utils/AppError"; import { IErrorPayload, ISuccessPayload } from "src/types"; @@ -144,6 +147,47 @@ export const inviteMember = routeTryCatcher( }, ); +export const updateOrganization = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const input: UpdateOrganizationInput = req.body; + const orgId = req.userOrg!._id.toString(); + + const result = await OrganizationService.updateOrganization(orgId, input); + + if ((result as IErrorPayload).error) { + const error = (result as IErrorPayload).error; + if (error === "Organization not found") + return next(AppError.notFound(error)); + return next(AppError.badRequest(error || "Organization update failed")); + } + + const organization = (result as ISuccessPayload).data; + + const output: UpdateOrganizationOutput = { + organization: { + id: organization._id.toString(), + name: organization.name, + slug: organization.slug, + ...(organization.domain && { domain: organization.domain }), + ...(organization.description && { + description: organization.description, + }), + status: organization.status, + size: organization.size, + settings: organization.settings, + createdAt: organization.createdAt, + updatedAt: organization.updatedAt, + }, + }; + + return res.status(200).json({ + success: true, + message: "Organization updated successfully", + data: output, + }); + }, +); + export const acceptInvite = routeTryCatcher( async (req: Request, res: Response, next: NextFunction) => { const session = await mongoose.startSession(); diff --git a/src/modules/organization/organization.service.ts b/src/modules/organization/organization.service.ts index c701d23..16aa3fc 100644 --- a/src/modules/organization/organization.service.ts +++ b/src/modules/organization/organization.service.ts @@ -11,6 +11,7 @@ import { InviteMemberOutput, GetUserOrganizationOutput, PendingMembershipData, + UpdateOrganizationInput, } from "./organization.types"; import { ISuccessPayload, IErrorPayload } from "src/types"; import { generateRandomTokenWithCrypto } from "@utils/generators"; @@ -208,6 +209,27 @@ const OrganizationService = { } }, + updateOrganization: async ( + orgId: string, + input: UpdateOrganizationInput, + ): Promise | IErrorPayload> => { + try { + const organization = await OrganizationModel.findByIdAndUpdate( + orgId, + { $set: input }, + { new: true, runValidators: true }, + ); + + if (!organization) { + return { success: false, error: "Organization not found" }; + } + + return { success: true, data: organization }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }, + inviteMember: async ( inviter: IUser, input: InviteMemberInput, diff --git a/src/modules/organization/organization.types.ts b/src/modules/organization/organization.types.ts index a45c380..5c8e835 100644 --- a/src/modules/organization/organization.types.ts +++ b/src/modules/organization/organization.types.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { createOrganizationSchema, acceptInviteSchema, + updateOrganizationSchema, } from "./organization.validators"; import { OrgStatus, UserRole } from "@constants"; @@ -86,6 +87,26 @@ export type GetUserOrganizationOutput = { role: string | UserRole; }; +export type UpdateOrganizationInput = z.infer; + +export type UpdateOrganizationOutput = { + organization: { + id: string; + name: string; + slug: string; + domain?: string; + description?: string; + status: OrgStatus; + size: number; + settings: { + timezone: string; + workHours: number; + }; + createdAt: Date; + updatedAt: Date; + }; +}; + export type PendingMembershipData = { orgId: string; userId?: string; diff --git a/src/modules/organization/organization.validators.ts b/src/modules/organization/organization.validators.ts index 0e95d20..02f7fe7 100644 --- a/src/modules/organization/organization.validators.ts +++ b/src/modules/organization/organization.validators.ts @@ -24,3 +24,28 @@ export const inviteMemberSchema = z.object({ export const acceptInviteSchema = z.object({ token: z.string({ required_error: "Token is required" }), }); + +export const updateOrganizationSchema = z.object({ + name: z + .string() + .min(2, "Organization name must be at least 2 characters long") + .max(100, "Organization name must not exceed 100 characters") + .optional(), + domain: z.string().optional(), + description: z + .string() + .max(500, "Description must not exceed 500 characters") + .optional(), + status: z.enum(["ACTIVE", "INACTIVE"]).optional(), + size: z + .number() + .int() + .min(1, "Organization size must be at least 1") + .optional(), + settings: z + .object({ + timezone: z.string().optional(), + workHours: z.number().int().min(1).optional(), + }) + .optional(), +}); diff --git a/src/modules/organization/routes/organization.v1.routes.ts b/src/modules/organization/routes/organization.v1.routes.ts index e86039b..cbcc876 100644 --- a/src/modules/organization/routes/organization.v1.routes.ts +++ b/src/modules/organization/routes/organization.v1.routes.ts @@ -8,6 +8,7 @@ import { createOrganizationSchema, inviteMemberSchema, acceptInviteSchema, + updateOrganizationSchema, } from "../organization.validators"; import { createOrganization, @@ -15,6 +16,7 @@ import { getOrganizationMembers, inviteMember, acceptInvite, + updateOrganization, } from "../organization.controller"; const organizationRouter = Router(); @@ -28,6 +30,14 @@ organizationRouter.post( organizationRouter.get("/", authenticate, getOrganization); +organizationRouter.put( + "/", + authenticate, + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + validateResource(updateOrganizationSchema), + updateOrganization, +); + organizationRouter.get( "/members", authenticate, diff --git a/src/server.ts b/src/server.ts index 5678f13..b85e9ff 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,6 +17,13 @@ process.on("unhandledRejection", (reason) => { }); process.on("uncaughtException", (err) => { + if (err && (err as NodeJS.ErrnoException).code === "ERR_HTTP_HEADERS_SENT") { + logger.warn( + { err }, + "Suppressed ERR_HTTP_HEADERS_SENT (headers already sent)", + ); + return; + } logger.fatal({ err }, "Uncaught Exception — shutting down"); gracefulShutdown(1); });