From a5fc3b8fb0007ed3eda6c4610921e05937b60017 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Mon, 29 Dec 2025 15:59:43 +0100 Subject: [PATCH 01/12] feat(org): implement organization creation endpoint with membership module - Add POST /api/v1/org endpoint to create organizations - Create separate membership module with model, service, controller, routes, and docs - Create OWNER membership automatically when organization is created - Add comprehensive integration tests for organization creation - Fix unit tests for email verification and password reset (add save() calls for pre-save hooks) - Update organization service to use membership service - Follow existing codebase patterns and DRY principles --- .../integration/changePassword.v1.test.ts | 22 +- .../integration/forgotPassword.v1.test.ts | 14 +- .../__tests__/integration/logout.v1.test.ts | 4 +- .../auth/__tests__/integration/me.v1.test.ts | 11 +- .../integration/refreshToken.v1.test.ts | 6 +- .../integration/resetPassword.v1.test.ts | 8 +- .../integration/verifyEmail.v1.test.ts | 4 +- .../__tests__/unit/resetPassword.unit.test.ts | 20 +- .../__tests__/unit/verifyEmail.unit.test.ts | 18 +- src/modules/auth/auth.service.ts | 28 +- src/modules/auth/utils/auth.tokens.ts | 4 +- .../membership/membership.controller.ts | 30 ++ src/modules/membership/membership.docs.ts | 24 + src/modules/membership/membership.model.ts | 37 ++ src/modules/membership/membership.service.ts | 51 ++ src/modules/membership/membership.types.ts | 25 + .../membership/membership.validators.ts | 10 + .../membership/routes/membership.v1.routes.ts | 16 + .../integration/createOrganization.v1.test.ts | 438 ++++++++++++++++++ .../organization/organization.controller.ts | 34 ++ src/modules/organization/organization.docs.ts | 24 + .../organization/organization.model.ts | 4 +- .../organization/organization.service.ts | 87 ++++ .../organization/organization.types.ts | 11 +- .../organization/organization.validators.ts | 11 + .../routes/organization.v1.routes.ts | 16 + src/modules/user/user.model.ts | 12 - src/modules/user/user.service.ts | 15 + src/modules/user/user.types.ts | 4 - src/routes/v1.route.ts | 4 + 30 files changed, 894 insertions(+), 98 deletions(-) create mode 100644 src/modules/membership/membership.controller.ts create mode 100644 src/modules/membership/membership.docs.ts create mode 100644 src/modules/membership/membership.model.ts create mode 100644 src/modules/membership/membership.service.ts create mode 100644 src/modules/membership/membership.types.ts create mode 100644 src/modules/membership/membership.validators.ts create mode 100644 src/modules/membership/routes/membership.v1.routes.ts create mode 100644 src/modules/organization/__tests__/integration/createOrganization.v1.test.ts create mode 100644 src/modules/organization/organization.controller.ts create mode 100644 src/modules/organization/organization.docs.ts create mode 100644 src/modules/organization/organization.service.ts create mode 100644 src/modules/organization/organization.validators.ts create mode 100644 src/modules/organization/routes/organization.v1.routes.ts diff --git a/src/modules/auth/__tests__/integration/changePassword.v1.test.ts b/src/modules/auth/__tests__/integration/changePassword.v1.test.ts index b818219..ff23af4 100644 --- a/src/modules/auth/__tests__/integration/changePassword.v1.test.ts +++ b/src/modules/auth/__tests__/integration/changePassword.v1.test.ts @@ -3,7 +3,7 @@ import app from "@app"; import { seedOneUserWithOrg } from "@tests/helpers/seed"; import { clearDB } from "@tests/utils"; import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; -import UserModel from "@modules/user/user.model"; +import UserService from "@modules/user/user.service"; import { compareHashedBcryptString } from "@utils/encryptors"; import { TEST_CONSTANTS, @@ -50,7 +50,7 @@ describe("POST /api/v1/auth/change-password", () => { }); it("should return 400 if currentPassword is missing", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -72,7 +72,7 @@ describe("POST /api/v1/auth/change-password", () => { }); it("should return 400 if newPassword is missing", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -94,7 +94,7 @@ describe("POST /api/v1/auth/change-password", () => { }); it("should return 400 if newPassword is less than 6 characters", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -117,7 +117,7 @@ describe("POST /api/v1/auth/change-password", () => { }); it("should return 400 if newPassword is the same as currentPassword", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -140,7 +140,7 @@ describe("POST /api/v1/auth/change-password", () => { }); it("should return 400 if current password is incorrect", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -164,7 +164,7 @@ describe("POST /api/v1/auth/change-password", () => { }); it("should successfully change password with valid credentials", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -188,7 +188,7 @@ describe("POST /api/v1/auth/change-password", () => { }); it("should update password in database after successful change", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -208,7 +208,7 @@ describe("POST /api/v1/auth/change-password", () => { expect(res.status).toBe(200); - const updatedUser = await UserModel.findById(user._id); + const updatedUser = await UserService.getUserById(user._id.toString()); if (!updatedUser) throw new Error("User not found"); const isNewPasswordValid = await compareHashedBcryptString( @@ -225,7 +225,7 @@ describe("POST /api/v1/auth/change-password", () => { }); it("should allow login with new password after change", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -253,7 +253,7 @@ describe("POST /api/v1/auth/change-password", () => { }); it("should not allow login with old password after change", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ diff --git a/src/modules/auth/__tests__/integration/forgotPassword.v1.test.ts b/src/modules/auth/__tests__/integration/forgotPassword.v1.test.ts index 2626b8e..e7482b5 100644 --- a/src/modules/auth/__tests__/integration/forgotPassword.v1.test.ts +++ b/src/modules/auth/__tests__/integration/forgotPassword.v1.test.ts @@ -3,9 +3,8 @@ import app from "@app"; import { seedOneUserWithOrg } from "@tests/helpers/seed"; import { clearDB } from "@tests/utils"; import { sendEmailWithTemplate } from "@services/email.service"; -import UserModel from "@modules/user/user.model"; import { TEST_CONSTANTS } from "../helpers/testHelpers"; - +import UserService from "@modules/user/user.service"; jest.mock("@services/email.service"); const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; @@ -88,7 +87,7 @@ describe("POST /api/v1/auth/forgot-password", () => { .post("/api/v1/auth/forgot-password") .send({ email: verifiedUserEmail }); - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); expect(user).toBeTruthy(); expect(user?.passwordResetCode).toBeTruthy(); expect(user?.passwordResetCodeExpiry).toBeTruthy(); @@ -160,9 +159,8 @@ describe("POST /api/v1/auth/forgot-password", () => { expect(firstRequest.status).toBe(200); - const userAfterFirst = await UserModel.findOne({ - email: verifiedUserEmail, - }); + const userAfterFirst = await UserService.getUserByEmail(verifiedUserEmail); + const firstExpiry = userAfterFirst?.passwordResetCodeExpiry; await new Promise((resolve) => setTimeout(resolve, 100)); @@ -171,9 +169,7 @@ describe("POST /api/v1/auth/forgot-password", () => { .post("/api/v1/auth/forgot-password") .send({ email: verifiedUserEmail }); - const userAfterSecond = await UserModel.findOne({ - email: verifiedUserEmail, - }); + const userAfterSecond = await UserService.getUserByEmail(verifiedUserEmail); const secondExpiry = userAfterSecond?.passwordResetCodeExpiry; expect(secondExpiry?.getTime()).toBeGreaterThan( diff --git a/src/modules/auth/__tests__/integration/logout.v1.test.ts b/src/modules/auth/__tests__/integration/logout.v1.test.ts index d2c2b75..2090d33 100644 --- a/src/modules/auth/__tests__/integration/logout.v1.test.ts +++ b/src/modules/auth/__tests__/integration/logout.v1.test.ts @@ -7,7 +7,7 @@ import { extractSignedCookie, } from "@tests/utils"; import { RefreshTokenModel } from "@modules/auth/refreshToken.model"; -import UserModel from "@modules/user/user.model"; +import UserService from "@modules/user/user.service"; import { TEST_CONSTANTS } from "../helpers/testHelpers"; const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; @@ -109,7 +109,7 @@ describe("Auth Logout", () => { ); if (!refreshTokenValue) throw new Error("Refresh token cookie not found"); - const userDoc = await UserModel.findOne({ email: verifiedUserEmail }); + const userDoc = await UserService.getUserByEmail(verifiedUserEmail); if (!userDoc) throw new Error("User not found"); const tokenDoc = await RefreshTokenModel.findOne({ diff --git a/src/modules/auth/__tests__/integration/me.v1.test.ts b/src/modules/auth/__tests__/integration/me.v1.test.ts index 9e520a8..ba3b50a 100644 --- a/src/modules/auth/__tests__/integration/me.v1.test.ts +++ b/src/modules/auth/__tests__/integration/me.v1.test.ts @@ -3,13 +3,13 @@ import app from "@app"; import { seedOneUserWithOrg } from "@tests/helpers/seed"; import { clearDB } from "@tests/utils"; import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; -import UserModel from "@modules/user/user.model"; import * as jwt from "jsonwebtoken"; import * as signature from "cookie-signature"; import { TEST_CONSTANTS, createSignedAccessTokenCookie, } from "../helpers/testHelpers"; +import UserService from "@modules/user/user.service"; const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; @@ -44,7 +44,7 @@ describe("GET /api/v1/auth/me", () => { }); it("should return 401 if access token has invalid signature", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -77,7 +77,7 @@ describe("GET /api/v1/auth/me", () => { }); it("should return 401 if access token is expired", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); // Create an expired token @@ -117,7 +117,7 @@ describe("GET /api/v1/auth/me", () => { }); it("should return 200 with user data when valid access token is provided", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -138,7 +138,7 @@ describe("GET /api/v1/auth/me", () => { }); it("should return correct user data structure", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -158,7 +158,6 @@ describe("GET /api/v1/auth/me", () => { expect(userData.id).toBe(user._id.toString()); expect(userData.email).toBe(user.email); - expect(userData.role).toBe(user.role); expect(userData.isEmailVerified).toBe(user.isEmailVerified); expect(userData.createdAt).toBeDefined(); expect(userData.updatedAt).toBeDefined(); diff --git a/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts b/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts index 55ce079..39963e9 100644 --- a/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts +++ b/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts @@ -6,11 +6,11 @@ import { RefreshTokenModel } from "@modules/auth/refreshToken.model"; import { hashWithCrypto } from "@utils/encryptors"; import { generateRandomTokenWithCrypto } from "@utils/generators"; import { convertTimeToMilliseconds } from "@utils/index"; -import UserModel from "@modules/user/user.model"; import * as cookie from "cookie"; import * as signature from "cookie-signature"; import { COOKIE_SECRET } from "@config/env"; import { TEST_CONSTANTS } from "../helpers/testHelpers"; +import UserService from "@modules/user/user.service"; const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; @@ -44,7 +44,7 @@ describe("Refresh Token", () => { }); it("should return 401 if refresh token is expired", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const rawRefreshToken = generateRandomTokenWithCrypto(64); @@ -65,7 +65,7 @@ describe("Refresh Token", () => { }); it("should return 401 if refresh token is revoked", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const rawRefreshToken = generateRandomTokenWithCrypto(64); diff --git a/src/modules/auth/__tests__/integration/resetPassword.v1.test.ts b/src/modules/auth/__tests__/integration/resetPassword.v1.test.ts index 03f545c..54f5670 100644 --- a/src/modules/auth/__tests__/integration/resetPassword.v1.test.ts +++ b/src/modules/auth/__tests__/integration/resetPassword.v1.test.ts @@ -3,10 +3,10 @@ import app from "@app"; import { seedOneUserWithOrg } from "@tests/helpers/seed"; import { clearDB } from "@tests/utils"; import { sendEmailWithTemplate } from "@services/email.service"; -import UserModel from "@modules/user/user.model"; import { convertTimeToMilliseconds } from "@utils/index"; import { compareHashedBcryptString } from "@utils/encryptors"; import { TEST_CONSTANTS } from "../helpers/testHelpers"; +import UserService from "@modules/user/user.service"; jest.mock("@services/email.service"); @@ -157,7 +157,7 @@ describe("POST /api/v1/auth/reset-password", () => { .post("/api/v1/auth/forgot-password") .send({ email: verifiedUserEmail }); - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (user) { user.passwordResetCodeExpiry = new Date( Date.now() - convertTimeToMilliseconds(1, "min"), @@ -221,7 +221,7 @@ describe("POST /api/v1/auth/reset-password", () => { newPassword: newPassword, }); - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); expect(user).toBeTruthy(); const isNewPasswordValid = await compareHashedBcryptString( @@ -260,7 +260,7 @@ describe("POST /api/v1/auth/reset-password", () => { expect(resetRes.status).toBe(200); - const user = await UserModel.findOne({ email: verifiedUserEmail }).lean(); + const user = await UserService.getUserByEmail(verifiedUserEmail); expect(user).toBeTruthy(); expect(user?.passwordResetCode).toBeNull(); expect(user?.passwordResetCodeExpiry).toBeNull(); diff --git a/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts b/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts index 57f7468..daa93fd 100644 --- a/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts +++ b/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts @@ -2,7 +2,7 @@ import request from "supertest"; import app from "@app"; import { UserFactory } from "@tests/factories/user.factory"; import { sendEmailWithTemplate } from "@services/email.service"; -import UserModel from "@modules/user/user.model"; +import UserService from "@modules/user/user.service"; import { convertTimeToMilliseconds } from "@utils/index"; import { clearDB } from "@tests/utils"; @@ -45,7 +45,7 @@ describe("Email Verification", () => { await request(app).post("/api/v1/auth/signup").send(user); - const userInDb = await UserModel.findOne({ email: user.email }); + const userInDb = await UserService.getUserByEmail(user.email); if (userInDb) { userInDb.emailVerificationCodeExpiry = new Date( Date.now() - convertTimeToMilliseconds(1, "min"), diff --git a/src/modules/auth/__tests__/unit/resetPassword.unit.test.ts b/src/modules/auth/__tests__/unit/resetPassword.unit.test.ts index fd2d914..80cefa9 100644 --- a/src/modules/auth/__tests__/unit/resetPassword.unit.test.ts +++ b/src/modules/auth/__tests__/unit/resetPassword.unit.test.ts @@ -1,24 +1,22 @@ -import UserModel from "@modules/user/user.model"; +import UserService from "@modules/user/user.service"; import { UserFactory } from "@tests/factories/user.factory"; import { convertTimeToMilliseconds } from "@utils/index"; describe("Password Reset Code Logic", () => { it("successfully generates a hashed password reset code", async () => { - const user = new UserModel({ + const user = await UserService.createUser({ ...UserFactory.generate(), - role: "owner", }); const code = user.generatePasswordResetCode(); - await user.save(); + await user.save(); // Save to trigger pre-save hook that sets expiry expect(code).toHaveLength(6); expect(code).not.toBe(user.passwordResetCode); expect(user.passwordResetCodeExpiry).toBeDefined(); }); it("fails verification if code is wrong", async () => { - const user = new UserModel({ + const user = await UserService.createUser({ ...UserFactory.generate(), - role: "owner", }); const code = user.generatePasswordResetCode(); await user.save(); @@ -29,9 +27,8 @@ describe("Password Reset Code Logic", () => { }); it("fails verification if code is expired", async () => { - const user = new UserModel({ + const user = await UserService.createUser({ ...UserFactory.generate(), - role: "owner", }); const code = user.generatePasswordResetCode(); await user.save(); @@ -44,20 +41,19 @@ describe("Password Reset Code Logic", () => { }); it("successfully verifies code with correct code", async () => { - const user = new UserModel({ + const user = await UserService.createUser({ ...UserFactory.generate(), - role: "owner", }); const code = user.generatePasswordResetCode(); await user.save(); + console.log(user.passwordResetCodeExpiry, code); const isCorrectCode = user.verifyPasswordResetCode(code); expect(isCorrectCode).toBe(true); }); it("clears password reset data after clearing", async () => { - const user = new UserModel({ + const user = await UserService.createUser({ ...UserFactory.generate(), - role: "owner", }); user.generatePasswordResetCode(); await user.save(); diff --git a/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts b/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts index 7ed0cba..af980b3 100644 --- a/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts +++ b/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts @@ -1,10 +1,10 @@ -import UserModel from "@modules/user/user.model"; +import UserService from "@modules/user/user.service"; import { UserFactory } from "@tests/factories/user.factory"; import { convertTimeToMilliseconds } from "@utils/index"; describe("Email Verification Code Logic", () => { it("successfully generates a hashed email verification code", async () => { - const user = new UserModel(UserFactory.generate()); + const user = await UserService.createUser(UserFactory.generate()); const code = user.generateEmailVerificationCode(); expect(code).toHaveLength(6); expect(code).not.toBe(user.emailVerificationCode); @@ -12,7 +12,7 @@ describe("Email Verification Code Logic", () => { }); it("fails verification if code is wrong", async () => { - const user = new UserModel(UserFactory.generate()); + const user = await UserService.createUser(UserFactory.generate()); const code = user.generateEmailVerificationCode(); const isCorrectCode = user.verifyEmailVerificationCode( code.split("").reverse().join(""), @@ -22,7 +22,7 @@ describe("Email Verification Code Logic", () => { }); it("fails verification if code is expired", async () => { - const user = new UserModel(UserFactory.generate()); + const user = await UserService.createUser(UserFactory.generate()); const code = user.generateEmailVerificationCode(); user.emailVerificationCodeExpiry = new Date( Date.now() - convertTimeToMilliseconds(60, "minutes"), @@ -33,24 +33,22 @@ describe("Email Verification Code Logic", () => { }); it("successfully verifies code with correct code", async () => { - const user = new UserModel({ + const user = await UserService.createUser({ ...UserFactory.generate(), - role: "owner", }); const code = user.generateEmailVerificationCode(); - await user.save(); + await user.save(); // Save to trigger pre-save hook that sets expiry const isCorrectCode = user.verifyEmailVerificationCode(code); expect(isCorrectCode).toBe(true); expect(user.isEmailVerified).toBe(true); }); it("clears verification data after clearing", async () => { - const user = new UserModel({ + const user = await UserService.createUser({ ...UserFactory.generate(), - role: "owner", }); user.generateEmailVerificationCode(); - await user.save(); + await user.save(); // Save to trigger pre-save hook that sets expiry expect(user.emailVerificationCode).toBeTruthy(); expect(user.emailVerificationCodeExpiry).toBeTruthy(); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 9df635a..2f25def 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,11 +1,11 @@ -import UserModel from "@modules/user/user.model"; +import UserService from "@modules/user/user.service"; import { + SignupInput, + SignupOutput, + SendEmailVerificationCodeOutput, EmailVerificationOutput, ForgotPasswordOutput, ResetPasswordOutput, - SendEmailVerificationCodeOutput, - SignupInput, - SignupOutput, } from "./auth.types"; import { IUser } from "@modules/user/user.types"; import { sendEmailWithTemplate } from "@services/email.service"; @@ -28,17 +28,11 @@ const AuthService = { signup: async ( input: SignupInput, ): Promise | IErrorPayload> => { - const { firstName, lastName, email, password } = input; - const existingUser = await UserModel.exists({ email }); + const existingUser = + (await UserService.getUserByEmail(input.email)) !== null; if (existingUser) return { success: false, error: "User already exists" }; - const createdUser = new UserModel({ - firstName, - lastName, - email, - password, - role: "owner", - }); + const createdUser = await UserService.createUser(input); await createdUser.save(); const res = await AuthService.sendVerificationEmail(createdUser); @@ -92,9 +86,7 @@ const AuthService = { code: string, email: string, ): Promise | IErrorPayload> => { - const user = await UserModel.findOne({ - email: email, - }); + const user = await UserService.getUserByEmail(email); if (!user) return { success: false, @@ -245,7 +237,7 @@ const AuthService = { email: string, ): Promise | IErrorPayload> => { try { - const user = await UserModel.findOne({ email }); + const user = await UserService.getUserByEmail(email); if (!user) { return { success: true, @@ -303,7 +295,7 @@ const AuthService = { newPassword: string, ): Promise | IErrorPayload> => { try { - const user = await UserModel.findOne({ email }); + const user = await UserService.getUserByEmail(email); if (!user) return { success: false, diff --git a/src/modules/auth/utils/auth.tokens.ts b/src/modules/auth/utils/auth.tokens.ts index ba90fab..9653606 100644 --- a/src/modules/auth/utils/auth.tokens.ts +++ b/src/modules/auth/utils/auth.tokens.ts @@ -6,9 +6,9 @@ import { hashWithCrypto } from "@utils/encryptors"; import { IUser } from "@modules/user/user.types"; import { AccessPayload } from "../auth.types"; import { DEFAULT_REFRESH_DAYS } from "@config/constants"; -import UserModel from "@modules/user/user.model"; import { convertTimeToMilliseconds } from "@utils/index"; import { RefreshTokenModel } from "../refreshToken.model"; +import UserService from "@modules/user/user.service"; export function verifyAccessToken(token: string): AccessPayload { return jwt.verify(token, JWT_SECRET) as AccessPayload; @@ -75,7 +75,7 @@ export async function rotateRefreshToken( }); throw new Error("Refresh token expired"); } - const user = await UserModel.findById(existing.user); + const user = await UserService.getUserById(existing.user.toString()); if (!user) throw new Error("User not found!"); const rawRefreshToken = generateRandomTokenWithCrypto( Number(process.env.REFRESH_TOKEN_BYTES || 64), diff --git a/src/modules/membership/membership.controller.ts b/src/modules/membership/membership.controller.ts new file mode 100644 index 0000000..7bc722c --- /dev/null +++ b/src/modules/membership/membership.controller.ts @@ -0,0 +1,30 @@ +import { Request, Response, NextFunction } from "express"; +import MembershipService from "./membership.service"; +import { + CreateMembershipInput, + CreateMembershipOutput, +} from "./membership.types"; +import AppError from "@utils/AppError"; +import { IErrorPayload, ISuccessPayload } from "src/types"; +import { routeTryCatcher } from "@utils/routeTryCatcher"; + +export const createMembership = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const input: CreateMembershipInput = req.body; + + const result = await MembershipService.createMembership(input); + + if ((result as IErrorPayload).error) + return next( + AppError.badRequest( + (result as IErrorPayload).error || "Membership creation failed", + ), + ); + + return res.status(201).json({ + success: true, + message: "Membership created successfully", + data: (result as ISuccessPayload).data, + }); + }, +); diff --git a/src/modules/membership/membership.docs.ts b/src/modules/membership/membership.docs.ts new file mode 100644 index 0000000..e4bb5f5 --- /dev/null +++ b/src/modules/membership/membership.docs.ts @@ -0,0 +1,24 @@ +import { Tspec } from "tspec"; +import { + CreateMembershipInput, + CreateMembershipOutput, +} from "./membership.types"; +import { ISuccessPayload, IErrorPayload } from "src/types"; + +export type MembershipApiSpec = Tspec.DefineApiSpec<{ + basePath: "/api/v1/membership"; + tags: ["Membership"]; + paths: { + "/": { + post: { + summary: "Create a new membership"; + body: CreateMembershipInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + }; + }; + }; + }; +}>; diff --git a/src/modules/membership/membership.model.ts b/src/modules/membership/membership.model.ts new file mode 100644 index 0000000..b3b687e --- /dev/null +++ b/src/modules/membership/membership.model.ts @@ -0,0 +1,37 @@ +import mongoose, { Schema } from "mongoose"; +import { IMembership } from "./membership.types"; + +const membershipSchema = new Schema( + { + orgId: { + type: Schema.Types.ObjectId, + ref: "Organization", + required: true, + }, + userId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + role: { + type: String, + enum: ["OWNER", "ADMIN", "MEMBER", "VIEWER"], + required: true, + }, + status: { + type: String, + enum: ["ACTIVE", "DISABLED", "PENDING"], + default: "ACTIVE", + }, + }, + { timestamps: true }, +); + +membershipSchema.index({ orgId: 1, userId: 1 }, { unique: true }); + +const MembershipModel = mongoose.model( + "Membership", + membershipSchema, +); + +export default MembershipModel; diff --git a/src/modules/membership/membership.service.ts b/src/modules/membership/membership.service.ts new file mode 100644 index 0000000..837fb2b --- /dev/null +++ b/src/modules/membership/membership.service.ts @@ -0,0 +1,51 @@ +import mongoose from "mongoose"; +import MembershipModel from "./membership.model"; +import { + CreateMembershipInput, + CreateMembershipOutput, + IMembership, +} from "./membership.types"; +import { ISuccessPayload, IErrorPayload } from "src/types"; + +const MembershipService = { + createMembership: async ( + input: CreateMembershipInput, + session?: mongoose.ClientSession, + ): Promise | IErrorPayload> => { + try { + const membership = new MembershipModel({ + orgId: input.orgId, + userId: input.userId, + role: input.role, + status: input.status || "ACTIVE", + }); + + if (session) await membership.save({ session }); + else await membership.save(); + + return { + success: true, + data: { + membershipId: membership._id.toString(), + }, + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + getMembershipById: async (id: string): Promise => { + return await MembershipModel.findById(id); + }, + + getMembershipsByUser: async (userId: string): Promise => { + return await MembershipModel.find({ + userId: new mongoose.Types.ObjectId(userId), + }); + }, +}; + +export default MembershipService; diff --git a/src/modules/membership/membership.types.ts b/src/modules/membership/membership.types.ts new file mode 100644 index 0000000..07acd39 --- /dev/null +++ b/src/modules/membership/membership.types.ts @@ -0,0 +1,25 @@ +import mongoose from "mongoose"; + +export type MembershipRole = "OWNER" | "ADMIN" | "MEMBER" | "VIEWER"; +export type MembershipStatus = "ACTIVE" | "DISABLED" | "PENDING"; + +export interface IMembership extends mongoose.Document { + _id: mongoose.Types.ObjectId; + orgId: mongoose.Types.ObjectId; + userId: mongoose.Types.ObjectId; + role: MembershipRole; + status: MembershipStatus; + createdAt: Date; + updatedAt: Date; +} + +export type CreateMembershipInput = { + orgId: string; + userId: string; + role: MembershipRole; + status?: MembershipStatus; +}; + +export type CreateMembershipOutput = { + membershipId: string; +}; diff --git a/src/modules/membership/membership.validators.ts b/src/modules/membership/membership.validators.ts new file mode 100644 index 0000000..fa24e31 --- /dev/null +++ b/src/modules/membership/membership.validators.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const createMembershipSchema = z.object({ + orgId: z.string().min(1, "Organization ID is required"), + userId: z.string().min(1, "User ID is required"), + role: z.enum(["OWNER", "ADMIN", "MEMBER", "VIEWER"], { + errorMap: () => ({ message: "Invalid role" }), + }), + status: z.enum(["ACTIVE", "DISABLED", "PENDING"]).optional(), +}); diff --git a/src/modules/membership/routes/membership.v1.routes.ts b/src/modules/membership/routes/membership.v1.routes.ts new file mode 100644 index 0000000..461ea89 --- /dev/null +++ b/src/modules/membership/routes/membership.v1.routes.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; +import validateResource from "@middlewares/validators"; +import authenticate from "@middlewares/authenticate"; +import { createMembershipSchema } from "../membership.validators"; +import { createMembership } from "../membership.controller"; + +const membershipRouter = Router(); + +membershipRouter.post( + "/", + authenticate, + validateResource(createMembershipSchema), + createMembership, +); + +export default membershipRouter; diff --git a/src/modules/organization/__tests__/integration/createOrganization.v1.test.ts b/src/modules/organization/__tests__/integration/createOrganization.v1.test.ts new file mode 100644 index 0000000..5f09e06 --- /dev/null +++ b/src/modules/organization/__tests__/integration/createOrganization.v1.test.ts @@ -0,0 +1,438 @@ +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 { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "@modules/auth/__tests__/helpers/testHelpers"; +import { UserFactory } from "@tests/factories/user.factory"; +import { IUser } from "@modules/user/user.types"; +import OrganizationService from "@modules/organization/organization.service"; +import MembershipService from "@modules/membership/membership.service"; + +const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; + +beforeEach(async () => { + await clearDB(); +}); + +describe("POST /api/v1/org", () => { + describe("Authentication", () => { + it("should return 401 if access token cookie is missing", async () => { + const res = await request(app).post("/api/v1/org").send({ + name: "Test Organization", + size: 10, + }); + + 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) + .post("/api/v1/org") + .set("Cookie", ["access_token=invalid_token"]) + .send({ + name: "Test Organization", + size: 10, + }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("should return 401 if user in token does not exist", async () => { + const nonExistentUserId = "507f1f77bcf86cd799439011"; + const accessToken = generateAccessToken({ + id: nonExistentUserId, + email: "nonexistent@example.com", + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + size: 10, + }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("User not found"); + }); + }); + + describe("Validation", () => { + let user: IUser; + let cookie: string; + + beforeEach(async () => { + const userData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + 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, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 400 if name is missing", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + size: 10, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if name is too short", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "A", + size: 10, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect( + res.body.error?.toLowerCase().includes("at least 2 characters") || + 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) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "A".repeat(51), + size: 10, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + // Validation errors may be in error field or nested in errors + expect( + res.body.error?.toLowerCase().includes("at most 50 characters") || + JSON.stringify(res.body) + .toLowerCase() + .includes("at most 50 characters"), + ).toBe(true); + }); + + it("should return 400 if size is missing", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if size is less than 1", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + size: 0, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + // Validation errors may be in error field or nested in errors + expect( + res.body.error?.toLowerCase().includes("at least 1") || + 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) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + size: 10.5, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should accept valid optional fields (domain, description)", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + size: 10, + domain: "example.com", + description: "Test description", + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + }); + }); + + describe("Successful Organization Creation", () => { + let user: IUser; + let cookie: string; + + beforeEach(async () => { + const userData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + 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, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 201 and create organization with membership", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + size: 10, + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.message).toBe("Organization created successfully"); + expect(res.body.data).toHaveProperty("organizationId"); + expect(res.body.data).toHaveProperty("membershipId"); + }); + + it("should create organization in database with correct fields", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + size: 10, + domain: "example.com", + description: "Test description", + }); + + expect(res.status).toBe(201); + + const organization = await OrganizationService.getOrganizationById( + res.body.data.organizationId, + ); + + expect(organization).toBeDefined(); + expect(organization?.name).toBe("Test Organization"); + expect(organization?.size).toBe(10); + expect(organization?.domain).toBe("example.com"); + expect(organization?.description).toBe("Test description"); + expect(organization?.owner.toString()).toBe(user._id.toString()); + expect(organization?.status).toBe("ACTIVE"); + expect(organization?.slug).toBeDefined(); + expect(organization?.settings.timezone).toBe("UTC"); + expect(organization?.settings.workHours).toBe(8); + }); + + it("should create OWNER membership with ACTIVE status", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + size: 10, + }); + + expect(res.status).toBe(201); + + const membership = await MembershipService.getMembershipById( + res.body.data.membershipId, + ); + + expect(membership).toBeDefined(); + expect(membership?.role).toBe("OWNER"); + expect(membership?.status).toBe("ACTIVE"); + expect(membership?.orgId.toString()).toBe(res.body.data.organizationId); + expect(membership?.userId.toString()).toBe(user._id.toString()); + }); + + it("should generate unique slug for organization", async () => { + const res1 = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + size: 10, + }); + + expect(res1.status).toBe(201); + + const org1 = await OrganizationService.getOrganizationById( + res1.body.data.organizationId, + ); + const slug1 = org1?.slug; + + const res2 = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + size: 10, + }); + + expect(res2.status).toBe(201); + + const org2 = await OrganizationService.getOrganizationById( + res2.body.data.organizationId, + ); + const slug2 = org2?.slug; + + expect(slug1).toBeDefined(); + expect(slug2).toBeDefined(); + expect(slug1).not.toBe(slug2); + }); + + it("should allow user to create multiple organizations", async () => { + const res1 = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "First Organization", + size: 10, + }); + + const res2 = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Second Organization", + size: 20, + }); + + expect(res1.status).toBe(201); + expect(res2.status).toBe(201); + + const organizations = await OrganizationService.getOrganizationsByOwner( + user._id.toString(), + ); + + expect(organizations).toHaveLength(2); + }); + + it("should create membership for each organization", async () => { + const res1 = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "First Organization", + size: 10, + }); + + const res2 = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Second Organization", + size: 20, + }); + + expect(res1.status).toBe(201); + expect(res2.status).toBe(201); + + const memberships = await MembershipService.getMembershipsByUser( + user._id.toString(), + ); + + expect(memberships).toHaveLength(2); + expect(memberships[0]?.role).toBe("OWNER"); + expect(memberships[1]?.role).toBe("OWNER"); + }); + }); + + describe("Transaction Safety", () => { + let user: IUser; + let cookie: string; + + beforeEach(async () => { + const userData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + 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, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should handle transaction rollback correctly", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Transaction Test Organization", + size: 10, + }); + + expect(res.status).toBe(201); + const organization = await OrganizationService.getOrganizationById( + res.body.data.organizationId, + ); + const membership = await MembershipService.getMembershipById( + res.body.data.membershipId, + ); + + expect(organization).toBeDefined(); + expect(membership).toBeDefined(); + expect(membership?.orgId.toString()).toBe(organization?._id.toString()); + expect(membership?.userId.toString()).toBe(user._id.toString()); + }); + }); +}); diff --git a/src/modules/organization/organization.controller.ts b/src/modules/organization/organization.controller.ts new file mode 100644 index 0000000..58da5f6 --- /dev/null +++ b/src/modules/organization/organization.controller.ts @@ -0,0 +1,34 @@ +import { Request, Response, NextFunction } from "express"; +import OrganizationService from "./organization.service"; +import { + CreateOrganizationInput, + CreateOrganizationOutput, +} from "./organization.types"; +import AppError from "@utils/AppError"; +import { IErrorPayload, ISuccessPayload } from "src/types"; +import { routeTryCatcher } from "@utils/routeTryCatcher"; +import { IUser } from "@modules/user/user.types"; + +export const createOrganization = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const input: CreateOrganizationInput = req.body; + + const result = await OrganizationService.createOrganization( + req.user as IUser, + input, + ); + + if ((result as IErrorPayload).error) + return next( + AppError.badRequest( + (result as IErrorPayload).error || "Organization creation failed", + ), + ); + + return res.status(201).json({ + success: true, + message: "Organization created successfully", + data: (result as ISuccessPayload).data, + }); + }, +); diff --git a/src/modules/organization/organization.docs.ts b/src/modules/organization/organization.docs.ts new file mode 100644 index 0000000..7ee9bbb --- /dev/null +++ b/src/modules/organization/organization.docs.ts @@ -0,0 +1,24 @@ +import { Tspec } from "tspec"; +import { + CreateOrganizationInput, + CreateOrganizationOutput, +} from "./organization.types"; +import { ISuccessPayload, IErrorPayload } from "src/types"; + +export type OrganizationApiSpec = Tspec.DefineApiSpec<{ + basePath: "/api/v1/org"; + tags: ["Organization"]; + paths: { + "/": { + post: { + summary: "Create a new organization"; + body: CreateOrganizationInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + }; + }; + }; + }; +}>; diff --git a/src/modules/organization/organization.model.ts b/src/modules/organization/organization.model.ts index 20e2607..a819d28 100644 --- a/src/modules/organization/organization.model.ts +++ b/src/modules/organization/organization.model.ts @@ -21,8 +21,8 @@ const organizationSchema = new mongoose.Schema( description: { type: String, trim: true }, status: { type: String, - enum: ["active", "inactive"], - default: "active", + enum: ["ACTIVE", "INACTIVE"], + default: "ACTIVE", }, size: { type: Number, diff --git a/src/modules/organization/organization.service.ts b/src/modules/organization/organization.service.ts new file mode 100644 index 0000000..9b41015 --- /dev/null +++ b/src/modules/organization/organization.service.ts @@ -0,0 +1,87 @@ +import mongoose from "mongoose"; +import OrganizationModel from "./organization.model"; +import MembershipService from "@modules/membership/membership.service"; +import { IUser } from "@modules/user/user.types"; +import { + CreateOrganizationInput, + CreateOrganizationOutput, + IOrganization, +} from "./organization.types"; +import { ISuccessPayload, IErrorPayload } from "src/types"; + +const OrganizationService = { + createOrganization: async ( + user: IUser, + input: CreateOrganizationInput, + ): Promise | IErrorPayload> => { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + const organization = new OrganizationModel({ + name: input.name, + owner: user._id, + size: input.size, + domain: input.domain, + description: input.description, + }); + + await organization.save({ session }); + + const membershipResult = await MembershipService.createMembership( + { + orgId: organization._id.toString(), + userId: user._id.toString(), + role: "OWNER", + status: "ACTIVE", + }, + session, + ); + + if (!membershipResult.success) { + throw new Error( + (membershipResult as IErrorPayload).error || + "Failed to create membership", + ); + } + + await session.commitTransaction(); + + return { + success: true, + data: { + organizationId: organization._id.toString(), + membershipId: ( + membershipResult as ISuccessPayload<{ + membershipId: string; + }> + ).data.membershipId, + }, + }; + } catch (err) { + if (session.inTransaction()) { + await session.abortTransaction(); + } + return { + success: false, + error: (err as Error).message, + }; + } finally { + session.endSession(); + } + }, + + getOrganizationById: async (id: string): Promise => { + return await OrganizationModel.findById(id); + }, + + getOrganizationsByOwner: async ( + ownerId: string, + ): Promise => { + return await OrganizationModel.find({ + owner: new mongoose.Types.ObjectId(ownerId), + }); + }, +}; + +export default OrganizationService; diff --git a/src/modules/organization/organization.types.ts b/src/modules/organization/organization.types.ts index 2e0eb88..4525a7e 100644 --- a/src/modules/organization/organization.types.ts +++ b/src/modules/organization/organization.types.ts @@ -1,6 +1,8 @@ import mongoose from "mongoose"; +import { z } from "zod"; +import { createOrganizationSchema } from "./organization.validators"; -type Status = "active" | "inactive"; +type Status = "ACTIVE" | "INACTIVE"; export interface IOrganization extends mongoose.Document { _id: mongoose.Types.ObjectId; @@ -18,3 +20,10 @@ export interface IOrganization extends mongoose.Document { workHours: number; }; } + +export type CreateOrganizationInput = z.infer; + +export type CreateOrganizationOutput = { + organizationId: string; + membershipId: string; +}; diff --git a/src/modules/organization/organization.validators.ts b/src/modules/organization/organization.validators.ts new file mode 100644 index 0000000..0c765ce --- /dev/null +++ b/src/modules/organization/organization.validators.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const createOrganizationSchema = z.object({ + name: z + .string() + .min(2, "Organization name must be at least 2 characters") + .max(50, "Organization name must be at most 50 characters"), + size: z.number().int().min(1, "Organization size must be at least 1"), + domain: z.string().optional(), + description: z.string().optional(), +}); diff --git a/src/modules/organization/routes/organization.v1.routes.ts b/src/modules/organization/routes/organization.v1.routes.ts new file mode 100644 index 0000000..fcd7a3f --- /dev/null +++ b/src/modules/organization/routes/organization.v1.routes.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; +import validateResource from "@middlewares/validators"; +import authenticate from "@middlewares/authenticate"; +import { createOrganizationSchema } from "../organization.validators"; +import { createOrganization } from "../organization.controller"; + +const organizationRouter = Router(); + +organizationRouter.post( + "/", + authenticate, + validateResource(createOrganizationSchema), + createOrganization, +); + +export default organizationRouter; diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index f8e03f2..622057e 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -9,18 +9,6 @@ const userSchema = new Schema( lastName: { type: String, required: true, trim: true }, email: { type: String, required: true, unique: true, lowercase: true }, password: { type: String, required: true }, - role: { - type: String, - enum: ["owner", "admin", "member", "viewer"], - default: "member", - }, - organization: { - type: Schema.Types.ObjectId, - ref: "Organization", - required: function () { - return this.role === "member"; - }, - }, isEmailVerified: { type: Boolean, default: false }, emailVerificationCode: { type: String, default: null }, emailVerificationCodeExpiry: { type: Date, default: null }, diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index 7c55f2e..d5021cb 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -9,6 +9,21 @@ const UserService = { getUserById: async (id: string): Promise => { return await UserModel.findById(id); }, + + createUser: async ( + input: Pick, + ): Promise => { + const { firstName, lastName, email, password } = input; + const user = new UserModel({ + firstName, + lastName, + email, + password, + role: "owner", + }); + await user.save(); + return user; + }, }; export default UserService; diff --git a/src/modules/user/user.types.ts b/src/modules/user/user.types.ts index 640b429..f7b4a65 100644 --- a/src/modules/user/user.types.ts +++ b/src/modules/user/user.types.ts @@ -1,16 +1,12 @@ import mongoose from "mongoose"; -export type Role = "owner" | "admin" | "member" | "viewer"; - export interface IUser extends mongoose.Document { _id: mongoose.Types.ObjectId; firstName: string; lastName: string; email: string; password: string; - role: Role; permissions: string[]; - organization: mongoose.Types.ObjectId; createdAt: Date; updatedAt: Date; isEmailVerified: boolean; diff --git a/src/routes/v1.route.ts b/src/routes/v1.route.ts index 4983ef6..35d8dd0 100644 --- a/src/routes/v1.route.ts +++ b/src/routes/v1.route.ts @@ -1,7 +1,11 @@ import { Router } from "express"; import authRouter from "@modules/auth/routes/auth.v1.routes"; +import organizationRouter from "@modules/organization/routes/organization.v1.routes"; +import membershipRouter from "@modules/membership/routes/membership.v1.routes"; const v1Router = Router(); v1Router.use("/auth", authRouter); +v1Router.use("/org", organizationRouter); +v1Router.use("/membership", membershipRouter); export default v1Router; From 90f7c9778f166f46c83995df03fc9534b8e8d95c Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Mon, 29 Dec 2025 16:09:44 +0100 Subject: [PATCH 02/12] feat(org): add GET /org endpoint to retrieve organization with caller role - Add GET /api/v1/org endpoint that returns organization and user's role - Add getMembershipByUserAndOrg service method - Add getOrganizationWithUserRole service method - Update validateResource middleware to support query parameter validation - Remove console.log from resetPassword unit test - Add proper error handling (404 for not found, 403 for not a member) --- src/middlewares/validators.ts | 6 +- .../__tests__/unit/resetPassword.unit.test.ts | 1 - src/modules/membership/membership.service.ts | 10 ++++ .../organization/organization.controller.ts | 58 +++++++++++++++++++ src/modules/organization/organization.docs.ts | 14 +++++ .../organization/organization.service.ts | 46 +++++++++++++++ .../organization/organization.types.ts | 19 ++++++ .../organization/organization.validators.ts | 4 ++ .../routes/organization.v1.routes.ts | 17 +++++- 9 files changed, 170 insertions(+), 5 deletions(-) diff --git a/src/middlewares/validators.ts b/src/middlewares/validators.ts index 788bce8..0c80132 100644 --- a/src/middlewares/validators.ts +++ b/src/middlewares/validators.ts @@ -3,9 +3,11 @@ import { Request, Response, NextFunction } from "express"; import AppError from "@utils/AppError"; const validateResource = - (schema: ZodSchema) => (req: Request, _res: Response, next: NextFunction) => { + (schema: ZodSchema, source: "body" | "query" = "body") => + (req: Request, _res: Response, next: NextFunction) => { try { - schema.parse(req.body); + const data = source === "query" ? req.query : req.body; + schema.parse(data); next(); } catch (e: unknown) { return next( diff --git a/src/modules/auth/__tests__/unit/resetPassword.unit.test.ts b/src/modules/auth/__tests__/unit/resetPassword.unit.test.ts index 80cefa9..f818e56 100644 --- a/src/modules/auth/__tests__/unit/resetPassword.unit.test.ts +++ b/src/modules/auth/__tests__/unit/resetPassword.unit.test.ts @@ -46,7 +46,6 @@ describe("Password Reset Code Logic", () => { }); const code = user.generatePasswordResetCode(); await user.save(); - console.log(user.passwordResetCodeExpiry, code); const isCorrectCode = user.verifyPasswordResetCode(code); expect(isCorrectCode).toBe(true); }); diff --git a/src/modules/membership/membership.service.ts b/src/modules/membership/membership.service.ts index 837fb2b..2244029 100644 --- a/src/modules/membership/membership.service.ts +++ b/src/modules/membership/membership.service.ts @@ -46,6 +46,16 @@ const MembershipService = { userId: new mongoose.Types.ObjectId(userId), }); }, + + getMembershipByUserAndOrg: async ( + userId: string, + orgId: string, + ): Promise => { + return await MembershipModel.findOne({ + userId: new mongoose.Types.ObjectId(userId), + orgId: new mongoose.Types.ObjectId(orgId), + }); + }, }; export default MembershipService; diff --git a/src/modules/organization/organization.controller.ts b/src/modules/organization/organization.controller.ts index 58da5f6..93a1cdc 100644 --- a/src/modules/organization/organization.controller.ts +++ b/src/modules/organization/organization.controller.ts @@ -3,6 +3,8 @@ import OrganizationService from "./organization.service"; import { CreateOrganizationInput, CreateOrganizationOutput, + GetOrganizationOutput, + IOrganization, } from "./organization.types"; import AppError from "@utils/AppError"; import { IErrorPayload, ISuccessPayload } from "src/types"; @@ -32,3 +34,59 @@ export const createOrganization = routeTryCatcher( }); }, ); + +export const getOrganization = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const user = req.user as IUser; + if (!user) return next(AppError.unauthorized("User not found")); + + const orgId = req.query.orgId as string; + if (!orgId) { + return next(AppError.badRequest("Organization ID is required")); + } + + const result = await OrganizationService.getOrganizationWithUserRole( + orgId, + user._id.toString(), + ); + + if ((result as IErrorPayload).error) { + const error = (result as IErrorPayload).error; + if (error === "Organization not found") { + return next(AppError.notFound(error)); + } + return next(AppError.forbidden(error)); + } + + const { organization, role } = ( + result as ISuccessPayload<{ + organization: IOrganization; + role: string; + }> + ).data; + + const output: GetOrganizationOutput = { + 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, + }, + role, + }; + + return res.status(200).json({ + success: true, + message: "Organization retrieved successfully", + data: output, + }); + }, +); diff --git a/src/modules/organization/organization.docs.ts b/src/modules/organization/organization.docs.ts index 7ee9bbb..c4d89e3 100644 --- a/src/modules/organization/organization.docs.ts +++ b/src/modules/organization/organization.docs.ts @@ -2,6 +2,7 @@ import { Tspec } from "tspec"; import { CreateOrganizationInput, CreateOrganizationOutput, + GetOrganizationOutput, } from "./organization.types"; import { ISuccessPayload, IErrorPayload } from "src/types"; @@ -19,6 +20,19 @@ export type OrganizationApiSpec = Tspec.DefineApiSpec<{ 401: IErrorPayload; }; }; + get: { + summary: "Get organization with caller's role"; + query: { + orgId: string; + }; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; }; }; }>; diff --git a/src/modules/organization/organization.service.ts b/src/modules/organization/organization.service.ts index 9b41015..39e3b4b 100644 --- a/src/modules/organization/organization.service.ts +++ b/src/modules/organization/organization.service.ts @@ -82,6 +82,52 @@ const OrganizationService = { owner: new mongoose.Types.ObjectId(ownerId), }); }, + + getOrganizationWithUserRole: async ( + orgId: string, + userId: string, + ): Promise< + | ISuccessPayload<{ + organization: IOrganization; + role: string; + }> + | IErrorPayload + > => { + try { + const organization = await OrganizationModel.findById(orgId); + if (!organization) { + return { + success: false, + error: "Organization not found", + }; + } + + const membership = await MembershipService.getMembershipByUserAndOrg( + userId, + orgId, + ); + + if (!membership) { + return { + success: false, + error: "User is not a member of this organization", + }; + } + + return { + success: true, + data: { + organization, + role: membership.role, + }, + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, }; export default OrganizationService; diff --git a/src/modules/organization/organization.types.ts b/src/modules/organization/organization.types.ts index 4525a7e..80545b3 100644 --- a/src/modules/organization/organization.types.ts +++ b/src/modules/organization/organization.types.ts @@ -27,3 +27,22 @@ export type CreateOrganizationOutput = { organizationId: string; membershipId: string; }; + +export type GetOrganizationOutput = { + organization: { + id: string; + name: string; + slug: string; + domain?: string; + description?: string; + status: string; + size: number; + settings: { + timezone: string; + workHours: number; + }; + createdAt: Date; + updatedAt: Date; + }; + role: string; +}; diff --git a/src/modules/organization/organization.validators.ts b/src/modules/organization/organization.validators.ts index 0c765ce..f3464d0 100644 --- a/src/modules/organization/organization.validators.ts +++ b/src/modules/organization/organization.validators.ts @@ -9,3 +9,7 @@ export const createOrganizationSchema = z.object({ domain: z.string().optional(), description: z.string().optional(), }); + +export const getOrganizationSchema = z.object({ + orgId: z.string().min(1, "Organization ID is required"), +}); diff --git a/src/modules/organization/routes/organization.v1.routes.ts b/src/modules/organization/routes/organization.v1.routes.ts index fcd7a3f..ee8bf87 100644 --- a/src/modules/organization/routes/organization.v1.routes.ts +++ b/src/modules/organization/routes/organization.v1.routes.ts @@ -1,8 +1,14 @@ import { Router } from "express"; import validateResource from "@middlewares/validators"; import authenticate from "@middlewares/authenticate"; -import { createOrganizationSchema } from "../organization.validators"; -import { createOrganization } from "../organization.controller"; +import { + createOrganizationSchema, + getOrganizationSchema, +} from "../organization.validators"; +import { + createOrganization, + getOrganization, +} from "../organization.controller"; const organizationRouter = Router(); @@ -13,4 +19,11 @@ organizationRouter.post( createOrganization, ); +organizationRouter.get( + "/", + authenticate, + validateResource(getOrganizationSchema, "query"), + getOrganization, +); + export default organizationRouter; From 31545edde57d2899a60f8759e214d82165f097cd Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Mon, 29 Dec 2025 16:28:04 +0100 Subject: [PATCH 03/12] feat(org): enforce one organization per user and simplify GET endpoint - Update POST /org to return 409 if user already has an organization - Simplify GET /org to automatically retrieve user's organization (no orgId needed) - Add getUserOrganization service method - Add AppError.conflict() for 409 status code - Update all tests to reflect one organization per user constraint - Refactor getOrganization tests to use separate describe blocks - Update API documentation --- .../integration/createOrganization.v1.test.ts | 72 +----- .../integration/getOrganization.v1.test.ts | 219 ++++++++++++++++++ .../organization/organization.controller.ts | 28 +-- src/modules/organization/organization.docs.ts | 10 +- .../organization/organization.service.ts | 39 +++- .../organization/organization.validators.ts | 4 - .../routes/organization.v1.routes.ts | 12 +- src/utils/AppError.ts | 4 + 8 files changed, 280 insertions(+), 108 deletions(-) create mode 100644 src/modules/organization/__tests__/integration/getOrganization.v1.test.ts diff --git a/src/modules/organization/__tests__/integration/createOrganization.v1.test.ts b/src/modules/organization/__tests__/integration/createOrganization.v1.test.ts index 5f09e06..e1c7653 100644 --- a/src/modules/organization/__tests__/integration/createOrganization.v1.test.ts +++ b/src/modules/organization/__tests__/integration/createOrganization.v1.test.ts @@ -294,23 +294,8 @@ describe("POST /api/v1/org", () => { expect(membership?.userId.toString()).toBe(user._id.toString()); }); - it("should generate unique slug for organization", async () => { - const res1 = await request(app) - .post("/api/v1/org") - .set("Cookie", [cookie]) - .send({ - name: "Test Organization", - size: 10, - }); - - expect(res1.status).toBe(201); - - const org1 = await OrganizationService.getOrganizationById( - res1.body.data.organizationId, - ); - const slug1 = org1?.slug; - - const res2 = await request(app) + it("should generate slug for organization", async () => { + const res = await request(app) .post("/api/v1/org") .set("Cookie", [cookie]) .send({ @@ -318,19 +303,18 @@ describe("POST /api/v1/org", () => { size: 10, }); - expect(res2.status).toBe(201); + expect(res.status).toBe(201); - const org2 = await OrganizationService.getOrganizationById( - res2.body.data.organizationId, + const org = await OrganizationService.getOrganizationById( + res.body.data.organizationId, ); - const slug2 = org2?.slug; + const slug = org?.slug; - expect(slug1).toBeDefined(); - expect(slug2).toBeDefined(); - expect(slug1).not.toBe(slug2); + expect(slug).toBeDefined(); + expect(slug).toBe("test-organization"); }); - it("should allow user to create multiple organizations", async () => { + it("should return 409 if user already has an organization", async () => { const res1 = await request(app) .post("/api/v1/org") .set("Cookie", [cookie]) @@ -339,32 +323,7 @@ describe("POST /api/v1/org", () => { size: 10, }); - const res2 = await request(app) - .post("/api/v1/org") - .set("Cookie", [cookie]) - .send({ - name: "Second Organization", - size: 20, - }); - expect(res1.status).toBe(201); - expect(res2.status).toBe(201); - - const organizations = await OrganizationService.getOrganizationsByOwner( - user._id.toString(), - ); - - expect(organizations).toHaveLength(2); - }); - - it("should create membership for each organization", async () => { - const res1 = await request(app) - .post("/api/v1/org") - .set("Cookie", [cookie]) - .send({ - name: "First Organization", - size: 10, - }); const res2 = await request(app) .post("/api/v1/org") @@ -374,16 +333,9 @@ describe("POST /api/v1/org", () => { size: 20, }); - expect(res1.status).toBe(201); - expect(res2.status).toBe(201); - - const memberships = await MembershipService.getMembershipsByUser( - user._id.toString(), - ); - - expect(memberships).toHaveLength(2); - expect(memberships[0]?.role).toBe("OWNER"); - expect(memberships[1]?.role).toBe("OWNER"); + expect(res2.status).toBe(409); + expect(res2.body.success).toBe(false); + expect(res2.body.error).toBe("User already has an organization"); }); }); diff --git a/src/modules/organization/__tests__/integration/getOrganization.v1.test.ts b/src/modules/organization/__tests__/integration/getOrganization.v1.test.ts new file mode 100644 index 0000000..8ebc5d6 --- /dev/null +++ b/src/modules/organization/__tests__/integration/getOrganization.v1.test.ts @@ -0,0 +1,219 @@ +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"; + +const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; + +beforeEach(async () => { + await clearDB(); +}); + +describe("GET /api/v1/org", () => { + describe("Authentication", () => { + it("should return 401 if access token cookie is missing", async () => { + const res = await request(app).get("/api/v1/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) + .get("/api/v1/org") + .set("Cookie", ["access_token=invalid_token"]); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("should return 401 if user in token does not exist", async () => { + const nonExistentUserId = "507f1f77bcf86cd799439011"; + const accessToken = generateAccessToken({ + id: nonExistentUserId, + email: "nonexistent@example.com", + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app).get("/api/v1/org").set("Cookie", [cookie]); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("User not found"); + }); + }); + + describe("User Has No Organization", () => { + let user: IUser; + let cookie: string; + + beforeEach(async () => { + const userData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + 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, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 404 if user does not have an organization", async () => { + const res = await request(app).get("/api/v1/org").set("Cookie", [cookie]); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("User does not have an organization"); + }); + }); + + describe("Successful Organization Retrieval", () => { + let user: IUser; + let organizationId: string; + let cookie: string; + + beforeEach(async () => { + const userData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + user = await UserService.createUser({ + firstName: userData.firstName, + lastName: userData.lastName, + email: userData.email, + password: userData.password, + }); + user.isEmailVerified = true; + await user.save(); + + const createOrgResult = await OrganizationService.createOrganization( + user, + { + name: "Test Organization", + size: 10, + domain: "example.com", + description: "Test description", + }, + ); + + if (!createOrgResult.success) { + throw new Error("Failed to create organization"); + } + + organizationId = ( + createOrgResult as { success: true; data: { organizationId: string } } + ).data.organizationId; + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 200 and organization with user role", async () => { + const res = await request(app).get("/api/v1/org").set("Cookie", [cookie]); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toBe("Organization retrieved successfully"); + expect(res.body.data).toHaveProperty("organization"); + expect(res.body.data).toHaveProperty("role"); + }); + + it("should return correct organization data structure", async () => { + const res = await request(app).get("/api/v1/org").set("Cookie", [cookie]); + + expect(res.status).toBe(200); + + const { organization } = res.body.data; + + expect(organization.id).toBe(organizationId); + expect(organization.name).toBe("Test Organization"); + expect(organization.slug).toBeDefined(); + expect(organization.domain).toBe("example.com"); + expect(organization.description).toBe("Test description"); + expect(organization.status).toBe("ACTIVE"); + expect(organization.size).toBe(10); + expect(organization.settings.timezone).toBe("UTC"); + expect(organization.settings.workHours).toBe(8); + expect(organization.createdAt).toBeDefined(); + expect(organization.updatedAt).toBeDefined(); + }); + + it("should return OWNER role for organization creator", async () => { + const res = await request(app).get("/api/v1/org").set("Cookie", [cookie]); + + expect(res.status).toBe(200); + expect(res.body.data.role).toBe("OWNER"); + }); + }); + + describe("Organization Without Optional Fields", () => { + let user: IUser; + let cookie: string; + + beforeEach(async () => { + const userData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + user = await UserService.createUser({ + firstName: userData.firstName, + lastName: userData.lastName, + email: userData.email, + password: userData.password, + }); + user.isEmailVerified = true; + await user.save(); + + const createOrgResult = await OrganizationService.createOrganization( + user, + { + name: "Minimal Organization", + size: 5, + }, + ); + + if (!createOrgResult.success) { + throw new Error("Failed to create organization"); + } + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should handle organization without optional fields", async () => { + const res = await request(app).get("/api/v1/org").set("Cookie", [cookie]); + + expect(res.status).toBe(200); + const { organization: orgData } = res.body.data; + expect(orgData.name).toBe("Minimal Organization"); + expect(orgData.domain).toBeUndefined(); + expect(orgData.description).toBeUndefined(); + }); + }); +}); diff --git a/src/modules/organization/organization.controller.ts b/src/modules/organization/organization.controller.ts index 93a1cdc..deeb555 100644 --- a/src/modules/organization/organization.controller.ts +++ b/src/modules/organization/organization.controller.ts @@ -20,12 +20,13 @@ export const createOrganization = routeTryCatcher( input, ); - if ((result as IErrorPayload).error) - return next( - AppError.badRequest( - (result as IErrorPayload).error || "Organization creation failed", - ), - ); + if ((result as IErrorPayload).error) { + const error = (result as IErrorPayload).error; + if (error === "User already has an organization") { + return next(AppError.conflict(error)); + } + return next(AppError.badRequest(error || "Organization creation failed")); + } return res.status(201).json({ success: true, @@ -38,24 +39,19 @@ export const createOrganization = routeTryCatcher( export const getOrganization = routeTryCatcher( async (req: Request, res: Response, next: NextFunction) => { const user = req.user as IUser; - if (!user) return next(AppError.unauthorized("User not found")); - - const orgId = req.query.orgId as string; - if (!orgId) { - return next(AppError.badRequest("Organization ID is required")); - } - - const result = await OrganizationService.getOrganizationWithUserRole( - orgId, + const result = await OrganizationService.getUserOrganization( user._id.toString(), ); if ((result as IErrorPayload).error) { const error = (result as IErrorPayload).error; + if (error === "User does not have an organization") { + return next(AppError.notFound(error)); + } if (error === "Organization not found") { return next(AppError.notFound(error)); } - return next(AppError.forbidden(error)); + return next(AppError.badRequest(error)); } const { organization, role } = ( diff --git a/src/modules/organization/organization.docs.ts b/src/modules/organization/organization.docs.ts index c4d89e3..b5f9754 100644 --- a/src/modules/organization/organization.docs.ts +++ b/src/modules/organization/organization.docs.ts @@ -12,24 +12,20 @@ export type OrganizationApiSpec = Tspec.DefineApiSpec<{ paths: { "/": { post: { - summary: "Create a new organization"; + summary: "Create a new organization (one per user)"; body: CreateOrganizationInput; responses: { 201: ISuccessPayload; 400: IErrorPayload; 401: IErrorPayload; + 409: IErrorPayload; }; }; get: { - summary: "Get organization with caller's role"; - query: { - orgId: string; - }; + summary: "Get user's organization with caller's role"; responses: { 200: ISuccessPayload; - 400: IErrorPayload; 401: IErrorPayload; - 403: IErrorPayload; 404: IErrorPayload; }; }; diff --git a/src/modules/organization/organization.service.ts b/src/modules/organization/organization.service.ts index 39e3b4b..2d86648 100644 --- a/src/modules/organization/organization.service.ts +++ b/src/modules/organization/organization.service.ts @@ -14,6 +14,17 @@ const OrganizationService = { user: IUser, input: CreateOrganizationInput, ): Promise | IErrorPayload> => { + const existingMembership = await MembershipService.getMembershipsByUser( + user._id.toString(), + ); + + if (existingMembership.length > 0) { + return { + success: false, + error: "User already has an organization", + }; + } + const session = await mongoose.startSession(); session.startTransaction(); @@ -83,8 +94,7 @@ const OrganizationService = { }); }, - getOrganizationWithUserRole: async ( - orgId: string, + getUserOrganization: async ( userId: string, ): Promise< | ISuccessPayload<{ @@ -94,23 +104,30 @@ const OrganizationService = { | IErrorPayload > => { try { - const organization = await OrganizationModel.findById(orgId); - if (!organization) { + const memberships = await MembershipService.getMembershipsByUser(userId); + + if (memberships.length === 0) { return { success: false, - error: "Organization not found", + error: "User does not have an organization", }; } - const membership = await MembershipService.getMembershipByUserAndOrg( - userId, - orgId, - ); - + // Get the first (and only) membership + const membership = memberships[0]; if (!membership) { return { success: false, - error: "User is not a member of this organization", + error: "User does not have an organization", + }; + } + + const organization = await OrganizationModel.findById(membership.orgId); + + if (!organization) { + return { + success: false, + error: "Organization not found", }; } diff --git a/src/modules/organization/organization.validators.ts b/src/modules/organization/organization.validators.ts index f3464d0..0c765ce 100644 --- a/src/modules/organization/organization.validators.ts +++ b/src/modules/organization/organization.validators.ts @@ -9,7 +9,3 @@ export const createOrganizationSchema = z.object({ domain: z.string().optional(), description: z.string().optional(), }); - -export const getOrganizationSchema = z.object({ - orgId: z.string().min(1, "Organization ID is required"), -}); diff --git a/src/modules/organization/routes/organization.v1.routes.ts b/src/modules/organization/routes/organization.v1.routes.ts index ee8bf87..d35ac5e 100644 --- a/src/modules/organization/routes/organization.v1.routes.ts +++ b/src/modules/organization/routes/organization.v1.routes.ts @@ -1,10 +1,7 @@ import { Router } from "express"; import validateResource from "@middlewares/validators"; import authenticate from "@middlewares/authenticate"; -import { - createOrganizationSchema, - getOrganizationSchema, -} from "../organization.validators"; +import { createOrganizationSchema } from "../organization.validators"; import { createOrganization, getOrganization, @@ -19,11 +16,6 @@ organizationRouter.post( createOrganization, ); -organizationRouter.get( - "/", - authenticate, - validateResource(getOrganizationSchema, "query"), - getOrganization, -); +organizationRouter.get("/", authenticate, getOrganization); export default organizationRouter; diff --git a/src/utils/AppError.ts b/src/utils/AppError.ts index 71c3270..ae2d6e1 100644 --- a/src/utils/AppError.ts +++ b/src/utils/AppError.ts @@ -38,6 +38,10 @@ export default class AppError extends Error { return new AppError(message, 404, details); } + static conflict(message = "Conflict", details?: string) { + return new AppError(message, 409, details); + } + static internal(message = "Internal Server Error", details?: string) { return new AppError(message, 500, details, false); } From 8a02725885214daaa3e968ba12c5f603d9e2c540 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Mon, 29 Dec 2025 17:25:17 +0100 Subject: [PATCH 04/12] feat(org): add GET /org/members endpoint for OWNER/ADMIN - Add requireRole middleware for role-based access control - Implement getOrganizationMembers service method - Add getOrganizationMembers controller handler - Add GET /org/members route protected by OWNER/ADMIN roles - Add OrganizationMember and GetOrganizationMembersOutput types - Update API documentation - Add comprehensive integration tests (13 tests) - Update membership service to populate user data and sort by creation date --- src/middlewares/requireRole.ts | 44 ++ src/modules/membership/membership.service.ts | 8 + .../getOrganizationMembers.v1.test.ts | 427 ++++++++++++++++++ .../organization/organization.controller.ts | 41 ++ src/modules/organization/organization.docs.ts | 12 + .../organization/organization.service.ts | 65 ++- .../organization/organization.types.ts | 15 + .../routes/organization.v1.routes.ts | 9 + 8 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 src/middlewares/requireRole.ts create mode 100644 src/modules/organization/__tests__/integration/getOrganizationMembers.v1.test.ts diff --git a/src/middlewares/requireRole.ts b/src/middlewares/requireRole.ts new file mode 100644 index 0000000..627f7d1 --- /dev/null +++ b/src/middlewares/requireRole.ts @@ -0,0 +1,44 @@ +import { Request, Response, NextFunction } from "express"; +import AppError from "@utils/AppError"; +import { IUser } from "@modules/user/user.types"; +import OrganizationService from "@modules/organization/organization.service"; +import { MembershipRole } from "@modules/membership/membership.types"; +import { ISuccessPayload } from "src/types"; +import { IOrganization } from "@modules/organization/organization.types"; + +const requireRole = (allowedRoles: MembershipRole[]) => { + return async (req: Request, _res: Response, next: NextFunction) => { + const user = req.user as IUser; + + if (!user) { + return next(AppError.unauthorized("User not found")); + } + + const orgResult = await OrganizationService.getUserOrganization( + user._id.toString(), + ); + + if (!orgResult.success) { + return next(AppError.notFound("User does not have an organization")); + } + + const userRole = ( + orgResult as ISuccessPayload<{ + organization: IOrganization; + role: MembershipRole; + }> + ).data.role; + + if (!allowedRoles.includes(userRole)) { + return next( + AppError.forbidden( + `Access denied. Required roles: ${allowedRoles.join(", ")}`, + ), + ); + } + + next(); + }; +}; + +export default requireRole; diff --git a/src/modules/membership/membership.service.ts b/src/modules/membership/membership.service.ts index 2244029..3756112 100644 --- a/src/modules/membership/membership.service.ts +++ b/src/modules/membership/membership.service.ts @@ -56,6 +56,14 @@ const MembershipService = { orgId: new mongoose.Types.ObjectId(orgId), }); }, + + getMembershipsByOrg: async (orgId: string): Promise => { + return await MembershipModel.find({ + orgId: new mongoose.Types.ObjectId(orgId), + }) + .populate("userId", "firstName lastName email") + .sort({ createdAt: 1 }); + }, }; export default MembershipService; diff --git a/src/modules/organization/__tests__/integration/getOrganizationMembers.v1.test.ts b/src/modules/organization/__tests__/integration/getOrganizationMembers.v1.test.ts new file mode 100644 index 0000000..48602b4 --- /dev/null +++ b/src/modules/organization/__tests__/integration/getOrganizationMembers.v1.test.ts @@ -0,0 +1,427 @@ +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 MembershipService from "@modules/membership/membership.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"; + +const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; + +beforeEach(async () => { + await clearDB(); +}); + +describe("GET /api/v1/org/members", () => { + describe("Authentication", () => { + it("should return 401 if access token cookie is missing", async () => { + const res = await request(app).get("/api/v1/org/members"); + + 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) + .get("/api/v1/org/members") + .set("Cookie", ["access_token=invalid_token"]); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("should return 401 if user in token does not exist", async () => { + const nonExistentUserId = "507f1f77bcf86cd799439011"; + const accessToken = generateAccessToken({ + id: nonExistentUserId, + email: "nonexistent@example.com", + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("User not found"); + }); + }); + + describe("Authorization", () => { + let owner: IUser; + let admin: IUser; + let member: IUser; + let viewer: IUser; + let organizationId: string; + + beforeEach(async () => { + const ownerData = UserFactory.generate({ + email: "owner@example.com", + password: testPassword, + }); + owner = await UserService.createUser({ + firstName: ownerData.firstName, + lastName: ownerData.lastName, + email: ownerData.email, + password: ownerData.password, + }); + owner.isEmailVerified = true; + await owner.save(); + + const createOrgResult = await OrganizationService.createOrganization( + owner, + { + name: "Test Organization", + size: 10, + }, + ); + + if (!createOrgResult.success) { + throw new Error("Failed to create organization"); + } + + organizationId = ( + createOrgResult as { success: true; data: { organizationId: string } } + ).data.organizationId; + + const adminData = UserFactory.generate({ + email: "admin@example.com", + password: testPassword, + }); + admin = await UserService.createUser({ + firstName: adminData.firstName, + lastName: adminData.lastName, + email: adminData.email, + password: adminData.password, + }); + admin.isEmailVerified = true; + await admin.save(); + + await MembershipService.createMembership({ + orgId: organizationId, + userId: admin._id.toString(), + role: "ADMIN", + status: "ACTIVE", + }); + const memberData = UserFactory.generate({ + email: "member@example.com", + password: testPassword, + }); + member = await UserService.createUser({ + firstName: memberData.firstName, + lastName: memberData.lastName, + email: memberData.email, + password: memberData.password, + }); + member.isEmailVerified = true; + await member.save(); + + await MembershipService.createMembership({ + orgId: organizationId, + userId: member._id.toString(), + role: "MEMBER", + status: "ACTIVE", + }); + + const viewerData = UserFactory.generate({ + email: "viewer@example.com", + password: testPassword, + }); + viewer = await UserService.createUser({ + firstName: viewerData.firstName, + lastName: viewerData.lastName, + email: viewerData.email, + password: viewerData.password, + }); + viewer.isEmailVerified = true; + await viewer.save(); + + await MembershipService.createMembership({ + orgId: organizationId, + userId: viewer._id.toString(), + role: "VIEWER", + status: "ACTIVE", + }); + }); + + it("should return 403 if user is MEMBER", async () => { + const accessToken = generateAccessToken({ + id: member._id.toString(), + email: member.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain("Access denied"); + }); + + it("should return 403 if user is VIEWER", async () => { + const accessToken = generateAccessToken({ + id: viewer._id.toString(), + email: viewer.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain("Access denied"); + }); + + it("should return 200 if user is OWNER", async () => { + const accessToken = generateAccessToken({ + id: owner._id.toString(), + email: owner.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it("should return 200 if user is ADMIN", async () => { + const accessToken = generateAccessToken({ + id: admin._id.toString(), + email: admin.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + }); + + describe("User Has No Organization", () => { + let user: IUser; + let cookie: string; + + beforeEach(async () => { + const userData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + 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, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 404 if user does not have an organization", async () => { + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("User does not have an organization"); + }); + }); + + describe("Successful Member Retrieval", () => { + let owner: IUser; + let admin: IUser; + let member: IUser; + let organizationId: string; + let cookie: string; + + beforeEach(async () => { + const ownerData = UserFactory.generate({ + email: "owner@example.com", + password: testPassword, + }); + owner = await UserService.createUser({ + firstName: ownerData.firstName, + lastName: ownerData.lastName, + email: ownerData.email, + password: ownerData.password, + }); + owner.isEmailVerified = true; + await owner.save(); + + const createOrgResult = await OrganizationService.createOrganization( + owner, + { + name: "Test Organization", + size: 10, + }, + ); + + if (!createOrgResult.success) { + throw new Error("Failed to create organization"); + } + + organizationId = ( + createOrgResult as { success: true; data: { organizationId: string } } + ).data.organizationId; + + const adminData = UserFactory.generate({ + email: "admin@example.com", + password: testPassword, + }); + admin = await UserService.createUser({ + firstName: adminData.firstName, + lastName: adminData.lastName, + email: adminData.email, + password: adminData.password, + }); + admin.isEmailVerified = true; + await admin.save(); + + await MembershipService.createMembership({ + orgId: organizationId, + userId: admin._id.toString(), + role: "ADMIN", + status: "ACTIVE", + }); + + const memberData = UserFactory.generate({ + email: "member@example.com", + password: testPassword, + }); + member = await UserService.createUser({ + firstName: memberData.firstName, + lastName: memberData.lastName, + email: memberData.email, + password: memberData.password, + }); + member.isEmailVerified = true; + await member.save(); + + await MembershipService.createMembership({ + orgId: organizationId, + userId: member._id.toString(), + role: "MEMBER", + status: "ACTIVE", + }); + + const accessToken = generateAccessToken({ + id: owner._id.toString(), + email: owner.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 200 and list of members", async () => { + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toBe( + "Organization members retrieved successfully", + ); + expect(res.body.data).toHaveProperty("members"); + expect(Array.isArray(res.body.data.members)).toBe(true); + }); + + it("should return all members of the organization", async () => { + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(200); + expect(res.body.data.members).toHaveLength(3); + + const memberEmails = res.body.data.members.map( + (m: { email: string }) => m.email, + ); + expect(memberEmails).toContain(owner.email); + expect(memberEmails).toContain(admin.email); + expect(memberEmails).toContain(member.email); + }); + + it("should return correct member data structure", async () => { + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(200); + + const firstMember = res.body.data.members[0]; + expect(firstMember).toHaveProperty("membershipId"); + expect(firstMember).toHaveProperty("userId"); + expect(firstMember).toHaveProperty("firstName"); + expect(firstMember).toHaveProperty("lastName"); + expect(firstMember).toHaveProperty("email"); + expect(firstMember).toHaveProperty("role"); + expect(firstMember).toHaveProperty("status"); + expect(firstMember).toHaveProperty("joinedAt"); + }); + + it("should return members sorted by creation date", async () => { + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(200); + + const members = res.body.data.members; + expect(members.length).toBeGreaterThan(1); + + for (let i = 1; i < members.length; i++) { + const prevDate = new Date(members[i - 1].joinedAt).getTime(); + const currDate = new Date(members[i].joinedAt).getTime(); + expect(currDate).toBeGreaterThanOrEqual(prevDate); + } + }); + + it("should include correct roles for each member", async () => { + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(200); + + const members = res.body.data.members; + const ownerMember = members.find( + (m: { email: string }) => m.email === owner.email, + ); + const adminMember = members.find( + (m: { email: string }) => m.email === admin.email, + ); + const memberMember = members.find( + (m: { email: string }) => m.email === member.email, + ); + + expect(ownerMember.role).toBe("OWNER"); + expect(adminMember.role).toBe("ADMIN"); + expect(memberMember.role).toBe("MEMBER"); + }); + }); +}); diff --git a/src/modules/organization/organization.controller.ts b/src/modules/organization/organization.controller.ts index deeb555..84efd81 100644 --- a/src/modules/organization/organization.controller.ts +++ b/src/modules/organization/organization.controller.ts @@ -4,6 +4,7 @@ import { CreateOrganizationInput, CreateOrganizationOutput, GetOrganizationOutput, + GetOrganizationMembersOutput, IOrganization, } from "./organization.types"; import AppError from "@utils/AppError"; @@ -86,3 +87,43 @@ export const getOrganization = routeTryCatcher( }); }, ); + +export const getOrganizationMembers = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const user = req.user as IUser; + if (!user) return next(AppError.unauthorized("User not found")); + + const result = await OrganizationService.getOrganizationMembers( + user._id.toString(), + ); + + if ((result as IErrorPayload).error) { + const error = (result as IErrorPayload).error; + if (error === "User does not have an organization") { + return next(AppError.notFound(error)); + } + return next(AppError.badRequest(error)); + } + + const output: GetOrganizationMembersOutput = ( + result as ISuccessPayload<{ + members: Array<{ + membershipId: string; + userId: string; + firstName: string; + lastName: string; + email: string; + role: string; + status: string; + joinedAt: Date; + }>; + }> + ).data; + + return res.status(200).json({ + success: true, + message: "Organization members retrieved successfully", + data: output, + }); + }, +); diff --git a/src/modules/organization/organization.docs.ts b/src/modules/organization/organization.docs.ts index b5f9754..a5dc83c 100644 --- a/src/modules/organization/organization.docs.ts +++ b/src/modules/organization/organization.docs.ts @@ -3,6 +3,7 @@ import { CreateOrganizationInput, CreateOrganizationOutput, GetOrganizationOutput, + GetOrganizationMembersOutput, } from "./organization.types"; import { ISuccessPayload, IErrorPayload } from "src/types"; @@ -30,5 +31,16 @@ export type OrganizationApiSpec = Tspec.DefineApiSpec<{ }; }; }; + "/members": { + get: { + summary: "Get organization members (OWNER/ADMIN only)"; + responses: { + 200: ISuccessPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + }; }; }>; diff --git a/src/modules/organization/organization.service.ts b/src/modules/organization/organization.service.ts index 2d86648..294b280 100644 --- a/src/modules/organization/organization.service.ts +++ b/src/modules/organization/organization.service.ts @@ -113,7 +113,6 @@ const OrganizationService = { }; } - // Get the first (and only) membership const membership = memberships[0]; if (!membership) { return { @@ -145,6 +144,70 @@ const OrganizationService = { }; } }, + + getOrganizationMembers: async ( + userId: string, + ): Promise< + | ISuccessPayload<{ + members: Array<{ + membershipId: string; + userId: string; + firstName: string; + lastName: string; + email: string; + role: string; + status: string; + joinedAt: Date; + }>; + }> + | IErrorPayload + > => { + try { + const orgResult = await OrganizationService.getUserOrganization(userId); + + if (!orgResult.success) { + return { + success: false, + error: "User does not have an organization", + }; + } + + const organization = ( + orgResult as { + success: true; + data: { organization: IOrganization; role: string }; + } + ).data.organization; + + const memberships = await MembershipService.getMembershipsByOrg( + organization._id.toString(), + ); + + const members = memberships.map((membership) => { + const user = membership.userId as unknown as IUser; + return { + membershipId: membership._id.toString(), + userId: user._id.toString(), + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + role: membership.role, + status: membership.status, + joinedAt: membership.createdAt, + }; + }); + + return { + success: true, + data: { members }, + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, }; export default OrganizationService; diff --git a/src/modules/organization/organization.types.ts b/src/modules/organization/organization.types.ts index 80545b3..4c6989c 100644 --- a/src/modules/organization/organization.types.ts +++ b/src/modules/organization/organization.types.ts @@ -46,3 +46,18 @@ export type GetOrganizationOutput = { }; role: string; }; + +export type OrganizationMember = { + membershipId: string; + userId: string; + firstName: string; + lastName: string; + email: string; + role: string; + status: string; + joinedAt: Date; +}; + +export type GetOrganizationMembersOutput = { + members: OrganizationMember[]; +}; diff --git a/src/modules/organization/routes/organization.v1.routes.ts b/src/modules/organization/routes/organization.v1.routes.ts index d35ac5e..8a7afdc 100644 --- a/src/modules/organization/routes/organization.v1.routes.ts +++ b/src/modules/organization/routes/organization.v1.routes.ts @@ -1,10 +1,12 @@ import { Router } from "express"; import validateResource from "@middlewares/validators"; import authenticate from "@middlewares/authenticate"; +import requireRole from "@middlewares/requireRole"; import { createOrganizationSchema } from "../organization.validators"; import { createOrganization, getOrganization, + getOrganizationMembers, } from "../organization.controller"; const organizationRouter = Router(); @@ -18,4 +20,11 @@ organizationRouter.post( organizationRouter.get("/", authenticate, getOrganization); +organizationRouter.get( + "/members", + authenticate, + requireRole(["OWNER", "ADMIN"]), + getOrganizationMembers, +); + export default organizationRouter; From c8a66915b0d9e1d8e4a428c59f6d9c22e797e9c2 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Wed, 7 Jan 2026 05:05:10 +0100 Subject: [PATCH 05/12] refactor --- src/config/env.ts | 6 + src/middlewares/authenticate.ts | 1 + src/middlewares/errorHandler.ts | 3 - .../__tests__/unit/verifyEmail.unit.test.ts | 4 +- src/modules/membership/membership.model.ts | 23 +- src/modules/membership/membership.service.ts | 37 +- src/modules/membership/membership.types.ts | 22 +- .../membership/membership.validators.ts | 2 +- .../integration/createOrganization.v1.test.ts | 13 +- .../getOrganizationMembers.v1.test.ts | 8 +- .../integration/inviteMember.v1.test.ts | 765 ++++++++++++++++++ .../organization/organization.controller.ts | 46 +- src/modules/organization/organization.docs.ts | 17 +- .../organization/organization.service.ts | 144 +++- .../organization/organization.types.ts | 25 + .../organization/organization.validators.ts | 17 +- .../routes/organization.v1.routes.ts | 16 +- .../organization/utils/invitationEmail.ts | 47 ++ 18 files changed, 1132 insertions(+), 64 deletions(-) create mode 100644 src/modules/organization/__tests__/integration/inviteMember.v1.test.ts create mode 100644 src/modules/organization/utils/invitationEmail.ts diff --git a/src/config/env.ts b/src/config/env.ts index 1d7dd53..ff6de1f 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -16,6 +16,8 @@ const envSchema = z.object({ COOKIE_SECRET: z.string(), EMAIL_VERIFICATION_TEMPLATE_KEY: z.string(), PASSWORD_RESET_TEMPLATE_KEY: z.string(), + INVITATION_TEMPLATE_KEY: z.string(), + FRONTEND_BASE_URL: z.string().url(), }); const isTest = process.env.NODE_ENV === "test"; @@ -37,6 +39,8 @@ const env = isTest COOKIE_SECRET: "testsecret", EMAIL_VERIFICATION_TEMPLATE_KEY: "", PASSWORD_RESET_TEMPLATE_KEY: "", + INVITATION_TEMPLATE_KEY: "", + FRONTEND_BASE_URL: "", }, error: envSchema.safeParse(process.env).error, } @@ -61,4 +65,6 @@ export const { COOKIE_SECRET, EMAIL_VERIFICATION_TEMPLATE_KEY, PASSWORD_RESET_TEMPLATE_KEY, + INVITATION_TEMPLATE_KEY, + FRONTEND_BASE_URL, } = env.data; diff --git a/src/middlewares/authenticate.ts b/src/middlewares/authenticate.ts index 9644b63..8339435 100644 --- a/src/middlewares/authenticate.ts +++ b/src/middlewares/authenticate.ts @@ -24,6 +24,7 @@ const authenticate = async ( } req.user = user; + next(); } catch (err) { return next( diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts index bae7bac..0652497 100644 --- a/src/middlewares/errorHandler.ts +++ b/src/middlewares/errorHandler.ts @@ -10,9 +10,6 @@ const errorHandler = ( // eslint-disable-next-line @typescript-eslint/no-unused-vars next?: NextFunction, ) => { - if (!(err instanceof AppError)) { - console.log(err); - } if (err instanceof AppError) { return res.status(err.statusCode).json({ success: false, diff --git a/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts b/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts index af980b3..38c16fc 100644 --- a/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts +++ b/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts @@ -37,7 +37,7 @@ describe("Email Verification Code Logic", () => { ...UserFactory.generate(), }); const code = user.generateEmailVerificationCode(); - await user.save(); // Save to trigger pre-save hook that sets expiry + await user.save(); const isCorrectCode = user.verifyEmailVerificationCode(code); expect(isCorrectCode).toBe(true); expect(user.isEmailVerified).toBe(true); @@ -48,7 +48,7 @@ describe("Email Verification Code Logic", () => { ...UserFactory.generate(), }); user.generateEmailVerificationCode(); - await user.save(); // Save to trigger pre-save hook that sets expiry + await user.save(); expect(user.emailVerificationCode).toBeTruthy(); expect(user.emailVerificationCodeExpiry).toBeTruthy(); diff --git a/src/modules/membership/membership.model.ts b/src/modules/membership/membership.model.ts index b3b687e..51caaf7 100644 --- a/src/modules/membership/membership.model.ts +++ b/src/modules/membership/membership.model.ts @@ -11,11 +11,19 @@ const membershipSchema = new Schema( userId: { type: Schema.Types.ObjectId, ref: "User", - required: true, + required: function (this: IMembership) { + return this.status !== "PENDING"; + }, + }, + email: { + type: String, + required: function (this: IMembership) { + return this.status === "PENDING" && !this.userId; + }, }, role: { type: String, - enum: ["OWNER", "ADMIN", "MEMBER", "VIEWER"], + enum: ["OWNER", "MANAGER", "MEMBER", "VIEWER"], required: true, }, status: { @@ -23,12 +31,19 @@ const membershipSchema = new Schema( enum: ["ACTIVE", "DISABLED", "PENDING"], default: "ACTIVE", }, + invitationToken: { + type: String, + default: null, + }, + invitedBy: { + type: Schema.Types.ObjectId, + ref: "User", + default: null, + }, }, { timestamps: true }, ); -membershipSchema.index({ orgId: 1, userId: 1 }, { unique: true }); - const MembershipModel = mongoose.model( "Membership", membershipSchema, diff --git a/src/modules/membership/membership.service.ts b/src/modules/membership/membership.service.ts index 3756112..f106c81 100644 --- a/src/modules/membership/membership.service.ts +++ b/src/modules/membership/membership.service.ts @@ -13,12 +13,34 @@ const MembershipService = { session?: mongoose.ClientSession, ): Promise | IErrorPayload> => { try { - const membership = new MembershipModel({ + const membershipData: { + orgId: string; + userId?: string; + email?: string; + role: string; + status: string; + invitationToken?: string; + invitedBy?: string; + } = { orgId: input.orgId, - userId: input.userId, role: input.role, status: input.status || "ACTIVE", - }); + }; + + if (input.userId) { + membershipData.userId = input.userId; + } else if (input.email) { + membershipData.email = input.email; + } + + if (input.invitationToken) { + membershipData.invitationToken = input.invitationToken; + } + if (input.invitedBy) { + membershipData.invitedBy = input.invitedBy; + } + + const membership = new MembershipModel(membershipData); if (session) await membership.save({ session }); else await membership.save(); @@ -64,6 +86,15 @@ const MembershipService = { .populate("userId", "firstName lastName email") .sort({ createdAt: 1 }); }, + getMembershipByEmailAndOrg: async ( + email: string, + orgId: string, + ): Promise => { + return await MembershipModel.findOne({ + email, + orgId: new mongoose.Types.ObjectId(orgId), + }); + }, }; export default MembershipService; diff --git a/src/modules/membership/membership.types.ts b/src/modules/membership/membership.types.ts index 07acd39..b2b02e0 100644 --- a/src/modules/membership/membership.types.ts +++ b/src/modules/membership/membership.types.ts @@ -1,25 +1,41 @@ import mongoose from "mongoose"; -export type MembershipRole = "OWNER" | "ADMIN" | "MEMBER" | "VIEWER"; +export type MembershipRole = "OWNER" | "MANAGER" | "MEMBER" | "VIEWER"; export type MembershipStatus = "ACTIVE" | "DISABLED" | "PENDING"; export interface IMembership extends mongoose.Document { _id: mongoose.Types.ObjectId; orgId: mongoose.Types.ObjectId; - userId: mongoose.Types.ObjectId; + userId?: mongoose.Types.ObjectId | null; + email?: string | null; role: MembershipRole; status: MembershipStatus; + invitationToken?: string | null; + invitedBy?: mongoose.Types.ObjectId | null; createdAt: Date; updatedAt: Date; } export type CreateMembershipInput = { orgId: string; - userId: string; + userId?: string; + email?: string; role: MembershipRole; status?: MembershipStatus; + invitationToken?: string; + invitedBy?: string; }; export type CreateMembershipOutput = { membershipId: string; }; + +export type MembershipData = { + orgId: string; + userId?: string; + email?: string; + role: string; + status: string; + invitationToken?: string; + invitedBy?: string; +}; diff --git a/src/modules/membership/membership.validators.ts b/src/modules/membership/membership.validators.ts index fa24e31..dd076af 100644 --- a/src/modules/membership/membership.validators.ts +++ b/src/modules/membership/membership.validators.ts @@ -3,7 +3,7 @@ import { z } from "zod"; export const createMembershipSchema = z.object({ orgId: z.string().min(1, "Organization ID is required"), userId: z.string().min(1, "User ID is required"), - role: z.enum(["OWNER", "ADMIN", "MEMBER", "VIEWER"], { + role: z.enum(["OWNER", "MANAGER", "MEMBER", "VIEWER"], { errorMap: () => ({ message: "Invalid role" }), }), status: z.enum(["ACTIVE", "DISABLED", "PENDING"]).optional(), diff --git a/src/modules/organization/__tests__/integration/createOrganization.v1.test.ts b/src/modules/organization/__tests__/integration/createOrganization.v1.test.ts index e1c7653..2f4a272 100644 --- a/src/modules/organization/__tests__/integration/createOrganization.v1.test.ts +++ b/src/modules/organization/__tests__/integration/createOrganization.v1.test.ts @@ -128,18 +128,19 @@ describe("POST /api/v1/org", () => { .post("/api/v1/org") .set("Cookie", [cookie]) .send({ - name: "A".repeat(51), + name: "A".repeat(101), size: 10, }); expect(res.status).toBe(400); expect(res.body.success).toBe(false); - // Validation errors may be in error field or nested in errors expect( - res.body.error?.toLowerCase().includes("at most 50 characters") || + res.body.error + ?.toLowerCase() + .includes("must not exceed 100 characters") || JSON.stringify(res.body) .toLowerCase() - .includes("at most 50 characters"), + .includes("must not exceed 100 characters"), ).toBe(true); }); @@ -291,7 +292,7 @@ describe("POST /api/v1/org", () => { expect(membership?.role).toBe("OWNER"); expect(membership?.status).toBe("ACTIVE"); expect(membership?.orgId.toString()).toBe(res.body.data.organizationId); - expect(membership?.userId.toString()).toBe(user._id.toString()); + expect(membership?.userId?.toString()).toBe(user._id.toString()); }); it("should generate slug for organization", async () => { @@ -384,7 +385,7 @@ describe("POST /api/v1/org", () => { expect(organization).toBeDefined(); expect(membership).toBeDefined(); expect(membership?.orgId.toString()).toBe(organization?._id.toString()); - expect(membership?.userId.toString()).toBe(user._id.toString()); + expect(membership?.userId?.toString()).toBe(user._id.toString()); }); }); }); diff --git a/src/modules/organization/__tests__/integration/getOrganizationMembers.v1.test.ts b/src/modules/organization/__tests__/integration/getOrganizationMembers.v1.test.ts index 48602b4..4193cc5 100644 --- a/src/modules/organization/__tests__/integration/getOrganizationMembers.v1.test.ts +++ b/src/modules/organization/__tests__/integration/getOrganizationMembers.v1.test.ts @@ -109,7 +109,7 @@ describe("GET /api/v1/org/members", () => { await MembershipService.createMembership({ orgId: organizationId, userId: admin._id.toString(), - role: "ADMIN", + role: "MANAGER", status: "ACTIVE", }); const memberData = UserFactory.generate({ @@ -200,7 +200,7 @@ describe("GET /api/v1/org/members", () => { expect(res.body.success).toBe(true); }); - it("should return 200 if user is ADMIN", async () => { + it("should return 200 if user is MANAGER", async () => { const accessToken = generateAccessToken({ id: admin._id.toString(), email: admin.email, @@ -305,7 +305,7 @@ describe("GET /api/v1/org/members", () => { await MembershipService.createMembership({ orgId: organizationId, userId: admin._id.toString(), - role: "ADMIN", + role: "MANAGER", status: "ACTIVE", }); @@ -420,7 +420,7 @@ describe("GET /api/v1/org/members", () => { ); expect(ownerMember.role).toBe("OWNER"); - expect(adminMember.role).toBe("ADMIN"); + expect(adminMember.role).toBe("MANAGER"); expect(memberMember.role).toBe("MEMBER"); }); }); diff --git a/src/modules/organization/__tests__/integration/inviteMember.v1.test.ts b/src/modules/organization/__tests__/integration/inviteMember.v1.test.ts new file mode 100644 index 0000000..ac1ab0d --- /dev/null +++ b/src/modules/organization/__tests__/integration/inviteMember.v1.test.ts @@ -0,0 +1,765 @@ +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 MembershipService from "@modules/membership/membership.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 { sendEmailWithTemplate } from "@services/email.service"; + +jest.mock("@services/email.service"); + +const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; + +beforeEach(async () => { + await clearDB(); + jest.clearAllMocks(); + (sendEmailWithTemplate as jest.Mock).mockResolvedValue({ + success: true, + emailSent: true, + }); +}); + +describe("POST /api/v1/org/invite", () => { + describe("Authentication", () => { + it("should return 401 if access token cookie is missing", async () => { + const res = await request(app).post("/api/v1/org/invite").send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + 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) + .post("/api/v1/org/invite") + .set("Cookie", ["access_token=invalid_token"]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("should return 401 if user in token does not exist", async () => { + const nonExistentUserId = "507f1f77bcf86cd799439011"; + const accessToken = generateAccessToken({ + id: nonExistentUserId, + email: "nonexistent@example.com", + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("User not found"); + }); + }); + + describe("Authorization", () => { + let owner: IUser; + let admin: IUser; + let member: IUser; + let viewer: IUser; + let organizationId: string; + + beforeEach(async () => { + const ownerData = UserFactory.generate({ + email: "owner@example.com", + password: testPassword, + }); + owner = await UserService.createUser({ + firstName: ownerData.firstName, + lastName: ownerData.lastName, + email: ownerData.email, + password: ownerData.password, + }); + owner.isEmailVerified = true; + await owner.save(); + + const createOrgResult = await OrganizationService.createOrganization( + owner, + { + name: "Test Organization", + size: 10, + }, + ); + + if (!createOrgResult.success) { + throw new Error("Failed to create organization"); + } + + organizationId = ( + createOrgResult as { success: true; data: { organizationId: string } } + ).data.organizationId; + + const adminData = UserFactory.generate({ + email: "admin@example.com", + password: testPassword, + }); + admin = await UserService.createUser({ + firstName: adminData.firstName, + lastName: adminData.lastName, + email: adminData.email, + password: adminData.password, + }); + admin.isEmailVerified = true; + await admin.save(); + + await MembershipService.createMembership({ + orgId: organizationId, + userId: admin._id.toString(), + role: "MANAGER", + status: "ACTIVE", + }); + + const memberData = UserFactory.generate({ + email: "member@example.com", + password: testPassword, + }); + member = await UserService.createUser({ + firstName: memberData.firstName, + lastName: memberData.lastName, + email: memberData.email, + password: memberData.password, + }); + member.isEmailVerified = true; + await member.save(); + + await MembershipService.createMembership({ + orgId: organizationId, + userId: member._id.toString(), + role: "MEMBER", + status: "ACTIVE", + }); + + const viewerData = UserFactory.generate({ + email: "viewer@example.com", + password: testPassword, + }); + viewer = await UserService.createUser({ + firstName: viewerData.firstName, + lastName: viewerData.lastName, + email: viewerData.email, + password: viewerData.password, + }); + viewer.isEmailVerified = true; + await viewer.save(); + + await MembershipService.createMembership({ + orgId: organizationId, + userId: viewer._id.toString(), + role: "VIEWER", + status: "ACTIVE", + }); + }); + + it("should return 403 if user is MEMBER", async () => { + const accessToken = generateAccessToken({ + id: member._id.toString(), + email: member.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain("Access denied"); + }); + + it("should return 403 if user is VIEWER", async () => { + const accessToken = generateAccessToken({ + id: viewer._id.toString(), + email: viewer.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain("Access denied"); + }); + + it("should allow OWNER to invite", async () => { + const inviteeData = UserFactory.generate({ + email: "invitee@example.com", + password: testPassword, + }); + const invitee = await UserService.createUser({ + firstName: inviteeData.firstName, + lastName: inviteeData.lastName, + email: inviteeData.email, + password: inviteeData.password, + }); + invitee.isEmailVerified = true; + await invitee.save(); + + const accessToken = generateAccessToken({ + id: owner._id.toString(), + email: owner.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + }); + + it("should allow MANAGER to invite", async () => { + const inviteeData = UserFactory.generate({ + email: "invitee@example.com", + password: testPassword, + }); + const invitee = await UserService.createUser({ + firstName: inviteeData.firstName, + lastName: inviteeData.lastName, + email: inviteeData.email, + password: inviteeData.password, + }); + invitee.isEmailVerified = true; + await invitee.save(); + + const accessToken = generateAccessToken({ + id: admin._id.toString(), + email: admin.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + }); + }); + + describe("Validation", () => { + let owner: IUser; + let cookie: string; + + beforeEach(async () => { + const ownerData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + owner = await UserService.createUser({ + firstName: ownerData.firstName, + lastName: ownerData.lastName, + email: ownerData.email, + password: ownerData.password, + }); + owner.isEmailVerified = true; + await owner.save(); + + await OrganizationService.createOrganization(owner, { + name: "Test Organization", + size: 10, + }); + + const accessToken = generateAccessToken({ + id: owner._id.toString(), + email: owner.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 400 if email is missing", async () => { + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + role: "MEMBER", + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if email is invalid", async () => { + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invalid-email", + role: "MEMBER", + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if role is missing", async () => { + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if role is invalid", async () => { + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "INVALID_ROLE", + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + }); + + describe("User Has No Organization", () => { + let user: IUser; + let cookie: string; + + beforeEach(async () => { + const userData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + 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, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 404 if user does not have an organization", async () => { + const inviteeData = UserFactory.generate({ + email: "invitee@example.com", + password: testPassword, + }); + const invitee = await UserService.createUser({ + firstName: inviteeData.firstName, + lastName: inviteeData.lastName, + email: inviteeData.email, + password: inviteeData.password, + }); + invitee.isEmailVerified = true; + await invitee.save(); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("User does not have an organization"); + }); + }); + + describe("Business Logic", () => { + let owner: IUser; + let cookie: string; + let organizationId: string; + + beforeEach(async () => { + const ownerData = UserFactory.generate({ + email: "owner@example.com", + password: testPassword, + }); + owner = await UserService.createUser({ + firstName: ownerData.firstName, + lastName: ownerData.lastName, + email: ownerData.email, + password: ownerData.password, + }); + owner.isEmailVerified = true; + await owner.save(); + + const createOrgResult = await OrganizationService.createOrganization( + owner, + { + name: "Test Organization", + size: 10, + }, + ); + + if (!createOrgResult.success) { + throw new Error("Failed to create organization"); + } + + organizationId = ( + createOrgResult as { success: true; data: { organizationId: string } } + ).data.organizationId; + + const accessToken = generateAccessToken({ + id: owner._id.toString(), + email: owner.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 400 if user is already an active member", async () => { + const existingUserData = UserFactory.generate({ + email: "existing@example.com", + password: testPassword, + }); + const existingUser = await UserService.createUser({ + firstName: existingUserData.firstName, + lastName: existingUserData.lastName, + email: existingUserData.email, + password: existingUserData.password, + }); + existingUser.isEmailVerified = true; + await existingUser.save(); + + await MembershipService.createMembership({ + orgId: organizationId, + userId: existingUser._id.toString(), + role: "MEMBER", + status: "ACTIVE", + }); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "existing@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe( + "User is already a member of this organization", + ); + }); + + it("should return 400 if pending invitation already exists", async () => { + const inviteeData = UserFactory.generate({ + email: "invitee@example.com", + password: testPassword, + }); + const invitee = await UserService.createUser({ + firstName: inviteeData.firstName, + lastName: inviteeData.lastName, + email: inviteeData.email, + password: inviteeData.password, + }); + invitee.isEmailVerified = true; + await invitee.save(); + + const res1 = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res1.status).toBe(201); + + const res2 = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res2.status).toBe(400); + expect(res2.body.success).toBe(false); + expect(res2.body.error).toBe( + "An invitation has already been sent to this email", + ); + }); + + it("should allow inviting non-existent users", async () => { + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "nonexistent@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + + const membership = await MembershipService.getMembershipByEmailAndOrg( + "nonexistent@example.com", + organizationId, + ); + + expect(membership).toBeDefined(); + expect(membership?.email).toBe("nonexistent@example.com"); + expect(membership?.userId).toBeUndefined(); + expect(membership?.status).toBe("PENDING"); + }); + }); + + describe("Successful Invitation", () => { + let owner: IUser; + let cookie: string; + let organizationId: string; + + beforeEach(async () => { + const ownerData = UserFactory.generate({ + email: "owner@example.com", + password: testPassword, + }); + owner = await UserService.createUser({ + firstName: ownerData.firstName, + lastName: ownerData.lastName, + email: ownerData.email, + password: ownerData.password, + }); + owner.isEmailVerified = true; + await owner.save(); + + const createOrgResult = await OrganizationService.createOrganization( + owner, + { + name: "Test Organization", + size: 10, + }, + ); + + if (!createOrgResult.success) { + throw new Error("Failed to create organization"); + } + + organizationId = ( + createOrgResult as { success: true; data: { organizationId: string } } + ).data.organizationId; + + const accessToken = generateAccessToken({ + id: owner._id.toString(), + email: owner.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 201 and create pending membership", async () => { + const inviteeData = UserFactory.generate({ + email: "invitee@example.com", + password: testPassword, + }); + const invitee = await UserService.createUser({ + firstName: inviteeData.firstName, + lastName: inviteeData.lastName, + email: inviteeData.email, + password: inviteeData.password, + }); + invitee.isEmailVerified = true; + await invitee.save(); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.message).toBe("Invitation sent successfully"); + expect(res.body.data).toHaveProperty("invitationId"); + expect(res.body.data).toHaveProperty("emailSent"); + expect(res.body.data.emailSent).toBe(true); + }); + + it("should create pending membership in database", async () => { + const inviteeData = UserFactory.generate({ + email: "invitee@example.com", + password: testPassword, + }); + const invitee = await UserService.createUser({ + firstName: inviteeData.firstName, + lastName: inviteeData.lastName, + email: inviteeData.email, + password: inviteeData.password, + }); + invitee.isEmailVerified = true; + await invitee.save(); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MANAGER", + }); + + expect(res.status).toBe(201); + + const membership = await MembershipService.getMembershipByUserAndOrg( + invitee._id.toString(), + organizationId, + ); + + expect(membership).toBeDefined(); + expect(membership?.role).toBe("MANAGER"); + expect(membership?.status).toBe("PENDING"); + expect(membership?.invitedBy?.toString()).toBe(owner._id.toString()); + expect(membership?.invitationToken).toBeDefined(); + }); + + it("should send email with correct merge info", async () => { + const inviteeData = UserFactory.generate({ + email: "invitee@example.com", + password: testPassword, + }); + const invitee = await UserService.createUser({ + firstName: inviteeData.firstName, + lastName: inviteeData.lastName, + email: inviteeData.email, + password: inviteeData.password, + }); + invitee.isEmailVerified = true; + await invitee.save(); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(201); + expect(sendEmailWithTemplate).toHaveBeenCalledTimes(1); + + const call = (sendEmailWithTemplate as jest.Mock).mock.calls[0][0]; + expect(call.to[0].email_address.address).toBe("invitee@example.com"); + expect(call.merge_info.role).toBe("MEMBER"); + expect(call.merge_info.organizationName).toBe("Test Organization"); + expect(call.merge_info.name).toBe("invitee"); + expect(call.merge_info.acceptLink).toContain("/accept-invitation?token="); + expect(call.merge_info.verify_account_link).toBeDefined(); + expect(call.merge_info.invitationLinkExpiry).toBe("7 days"); + expect(call.merge_info.ownersName).toBeDefined(); + }); + + it("should work for different roles", async () => { + const roles = ["OWNER", "MANAGER", "MEMBER", "VIEWER"] as const; + + for (const role of roles) { + const inviteeData = UserFactory.generate({ + email: `invitee-${role.toLowerCase()}@example.com`, + password: testPassword, + }); + const invitee = await UserService.createUser({ + firstName: inviteeData.firstName, + lastName: inviteeData.lastName, + email: inviteeData.email, + password: inviteeData.password, + }); + invitee.isEmailVerified = true; + await invitee.save(); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: `invitee-${role.toLowerCase()}@example.com`, + role, + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + } + }); + + it("should create pending membership for existing users who are not members", async () => { + const existingUserData = UserFactory.generate({ + email: "existing@example.com", + password: testPassword, + }); + const existingUser = await UserService.createUser({ + firstName: existingUserData.firstName, + lastName: existingUserData.lastName, + email: existingUserData.email, + password: existingUserData.password, + }); + existingUser.isEmailVerified = true; + await existingUser.save(); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "existing@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + + const membership = await MembershipService.getMembershipByUserAndOrg( + existingUser._id.toString(), + organizationId, + ); + expect(membership).toBeDefined(); + expect(membership?.status).toBe("PENDING"); + expect(membership?.invitationToken).toBeDefined(); + expect(membership?.invitedBy?.toString()).toBe(owner._id.toString()); + }); + }); +}); diff --git a/src/modules/organization/organization.controller.ts b/src/modules/organization/organization.controller.ts index 84efd81..ef9e64d 100644 --- a/src/modules/organization/organization.controller.ts +++ b/src/modules/organization/organization.controller.ts @@ -5,7 +5,8 @@ import { CreateOrganizationOutput, GetOrganizationOutput, GetOrganizationMembersOutput, - IOrganization, + InviteMemberOutput, + GetUserOrganizationOutput, } from "./organization.types"; import AppError from "@utils/AppError"; import { IErrorPayload, ISuccessPayload } from "src/types"; @@ -56,10 +57,7 @@ export const getOrganization = routeTryCatcher( } const { organization, role } = ( - result as ISuccessPayload<{ - organization: IOrganization; - role: string; - }> + result as ISuccessPayload ).data; const output: GetOrganizationOutput = { @@ -106,18 +104,7 @@ export const getOrganizationMembers = routeTryCatcher( } const output: GetOrganizationMembersOutput = ( - result as ISuccessPayload<{ - members: Array<{ - membershipId: string; - userId: string; - firstName: string; - lastName: string; - email: string; - role: string; - status: string; - joinedAt: Date; - }>; - }> + result as ISuccessPayload ).data; return res.status(200).json({ @@ -127,3 +114,28 @@ export const getOrganizationMembers = routeTryCatcher( }); }, ); + +export const inviteMember = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const user = req.user as IUser; + + const result = await OrganizationService.inviteMember(user, req.body); + + if ((result as IErrorPayload).error) { + const error = (result as IErrorPayload).error; + if (error === "User does not have an organization") + return next(AppError.notFound(error)); + return next(AppError.badRequest(error || "Failed to send invitation")); + } + + const output: InviteMemberOutput = ( + result as ISuccessPayload + ).data; + + return res.status(201).json({ + success: true, + message: "Invitation sent successfully", + data: output, + }); + }, +); diff --git a/src/modules/organization/organization.docs.ts b/src/modules/organization/organization.docs.ts index a5dc83c..863d018 100644 --- a/src/modules/organization/organization.docs.ts +++ b/src/modules/organization/organization.docs.ts @@ -4,6 +4,8 @@ import { CreateOrganizationOutput, GetOrganizationOutput, GetOrganizationMembersOutput, + InviteMemberInput, + InviteMemberOutput, } from "./organization.types"; import { ISuccessPayload, IErrorPayload } from "src/types"; @@ -33,7 +35,7 @@ export type OrganizationApiSpec = Tspec.DefineApiSpec<{ }; "/members": { get: { - summary: "Get organization members (OWNER/ADMIN only)"; + summary: "Get organization members (OWNER/MANAGER only)"; responses: { 200: ISuccessPayload; 401: IErrorPayload; @@ -42,5 +44,18 @@ export type OrganizationApiSpec = Tspec.DefineApiSpec<{ }; }; }; + "/invite": { + post: { + summary: "Invite a member to the organization (OWNER/MANAGER only)"; + body: InviteMemberInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + }; }; }>; diff --git a/src/modules/organization/organization.service.ts b/src/modules/organization/organization.service.ts index 294b280..851017a 100644 --- a/src/modules/organization/organization.service.ts +++ b/src/modules/organization/organization.service.ts @@ -1,13 +1,21 @@ import mongoose from "mongoose"; import OrganizationModel from "./organization.model"; import MembershipService from "@modules/membership/membership.service"; +import UserService from "@modules/user/user.service"; import { IUser } from "@modules/user/user.types"; import { CreateOrganizationInput, CreateOrganizationOutput, IOrganization, + InviteMemberInput, + InviteMemberOutput, + GetUserOrganizationOutput, + PendingMembershipData, } from "./organization.types"; import { ISuccessPayload, IErrorPayload } from "src/types"; +import { generateRandomTokenWithCrypto } from "@utils/generators"; +import { sendInvitationEmail } from "./utils/invitationEmail"; +import { IMembership } from "@modules/membership/membership.types"; const OrganizationService = { createOrganization: async ( @@ -96,13 +104,7 @@ const OrganizationService = { getUserOrganization: async ( userId: string, - ): Promise< - | ISuccessPayload<{ - organization: IOrganization; - role: string; - }> - | IErrorPayload - > => { + ): Promise | IErrorPayload> => { try { const memberships = await MembershipService.getMembershipsByUser(userId); @@ -114,21 +116,19 @@ const OrganizationService = { } const membership = memberships[0]; - if (!membership) { + if (!membership) return { success: false, error: "User does not have an organization", }; - } const organization = await OrganizationModel.findById(membership.orgId); - if (!organization) { + if (!organization) return { success: false, error: "Organization not found", }; - } return { success: true, @@ -173,10 +173,7 @@ const OrganizationService = { } const organization = ( - orgResult as { - success: true; - data: { organization: IOrganization; role: string }; - } + orgResult as ISuccessPayload ).data.organization; const memberships = await MembershipService.getMembershipsByOrg( @@ -208,6 +205,123 @@ const OrganizationService = { }; } }, + + inviteMember: async ( + inviter: IUser, + input: InviteMemberInput, + ): Promise | IErrorPayload> => { + try { + const memberships = await MembershipService.getMembershipsByUser( + inviter._id.toString(), + ); + + if (memberships.length === 0) + return { + success: false, + error: "User does not have an organization", + }; + + const inviterMembership = memberships[0]; + const organization = await OrganizationService.getOrganizationById( + inviterMembership?.orgId?.toString() || "", + ); + + if (!organization) + return { + success: false, + error: "Organization not found", + }; + + const existingUser = await UserService.getUserByEmail(input.email); + + let existingMembership: IMembership | null = null; + if (existingUser) { + existingMembership = await MembershipService.getMembershipByUserAndOrg( + existingUser._id.toString(), + organization._id.toString(), + ); + } + + if (!existingMembership) { + existingMembership = await MembershipService.getMembershipByEmailAndOrg( + input.email, + organization._id.toString(), + ); + } + + if (existingMembership) { + if (existingMembership.status !== "PENDING") + return { + success: false, + error: "User is already a member of this organization", + }; + return { + success: false, + error: "An invitation has already been sent to this email", + }; + } + + const invitationToken = generateRandomTokenWithCrypto(32); + + const membershipData: PendingMembershipData = { + orgId: organization._id.toString(), + role: input.role, + status: "PENDING", + invitationToken, + invitedBy: inviter._id.toString(), + }; + + if (existingUser) { + membershipData.userId = existingUser._id.toString(); + } else { + membershipData.email = input.email; + } + + const membershipResult = + await MembershipService.createMembership(membershipData); + + if (!membershipResult.success) { + return { + success: false, + error: + (membershipResult as IErrorPayload).error || + "Failed to create membership invitation", + }; + } + + const membershipId = ( + membershipResult as ISuccessPayload<{ membershipId: string }> + ).data.membershipId; + + const owner = await UserService.getUserById( + organization.owner.toString(), + ); + const ownersName = owner + ? `${owner.firstName} ${owner.lastName}` + : "Organization Owner"; + + const emailSentResponse = await sendInvitationEmail({ + email: input.email, + role: input.role, + organization, + invitationToken, + ownersName, + }); + + return { + success: true, + data: { + invitationId: membershipId, + emailSent: emailSentResponse.emailSent || false, + }, + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, }; export default OrganizationService; diff --git a/src/modules/organization/organization.types.ts b/src/modules/organization/organization.types.ts index 4c6989c..11d7594 100644 --- a/src/modules/organization/organization.types.ts +++ b/src/modules/organization/organization.types.ts @@ -61,3 +61,28 @@ export type OrganizationMember = { export type GetOrganizationMembersOutput = { members: OrganizationMember[]; }; + +export type InviteMemberInput = { + email: string; + role: "OWNER" | "MANAGER" | "MEMBER" | "VIEWER"; +}; + +export type InviteMemberOutput = { + invitationId: string; + emailSent: boolean; +}; + +export type GetUserOrganizationOutput = { + organization: IOrganization; + role: string; +}; + +export type PendingMembershipData = { + orgId: string; + userId?: string; + email?: string; + role: "OWNER" | "MANAGER" | "MEMBER" | "VIEWER"; + status: "PENDING"; + invitationToken: string; + invitedBy: string; +}; diff --git a/src/modules/organization/organization.validators.ts b/src/modules/organization/organization.validators.ts index 0c765ce..408f38b 100644 --- a/src/modules/organization/organization.validators.ts +++ b/src/modules/organization/organization.validators.ts @@ -1,11 +1,22 @@ import { z } from "zod"; +import { emailSchema } from "@modules/auth/auth.validators"; export const createOrganizationSchema = z.object({ name: z .string() - .min(2, "Organization name must be at least 2 characters") - .max(50, "Organization name must be at most 50 characters"), + .min(2, "Organization name must be at least 2 characters long") + .max(100, "Organization name must not exceed 100 characters"), size: z.number().int().min(1, "Organization size must be at least 1"), domain: z.string().optional(), - description: z.string().optional(), + description: z + .string() + .max(500, "Description must not exceed 500 characters") + .optional(), +}); + +export const inviteMemberSchema = z.object({ + email: emailSchema, + role: z.enum(["OWNER", "MANAGER", "MEMBER", "VIEWER"], { + errorMap: () => ({ message: "Invalid role" }), + }), }); diff --git a/src/modules/organization/routes/organization.v1.routes.ts b/src/modules/organization/routes/organization.v1.routes.ts index 8a7afdc..a95c8da 100644 --- a/src/modules/organization/routes/organization.v1.routes.ts +++ b/src/modules/organization/routes/organization.v1.routes.ts @@ -2,11 +2,15 @@ import { Router } from "express"; import validateResource from "@middlewares/validators"; import authenticate from "@middlewares/authenticate"; import requireRole from "@middlewares/requireRole"; -import { createOrganizationSchema } from "../organization.validators"; +import { + createOrganizationSchema, + inviteMemberSchema, +} from "../organization.validators"; import { createOrganization, getOrganization, getOrganizationMembers, + inviteMember, } from "../organization.controller"; const organizationRouter = Router(); @@ -23,8 +27,16 @@ organizationRouter.get("/", authenticate, getOrganization); organizationRouter.get( "/members", authenticate, - requireRole(["OWNER", "ADMIN"]), + requireRole(["OWNER", "MANAGER"]), getOrganizationMembers, ); +organizationRouter.post( + "/invite", + authenticate, + requireRole(["OWNER", "MANAGER"]), + validateResource(inviteMemberSchema), + inviteMember, +); + export default organizationRouter; diff --git a/src/modules/organization/utils/invitationEmail.ts b/src/modules/organization/utils/invitationEmail.ts new file mode 100644 index 0000000..41780ff --- /dev/null +++ b/src/modules/organization/utils/invitationEmail.ts @@ -0,0 +1,47 @@ +import { sendEmailWithTemplate } from "@services/email.service"; +import { FRONTEND_BASE_URL, INVITATION_TEMPLATE_KEY } from "@config/env"; +import { IOrganization } from "../organization.types"; +import { InviteMemberInput } from "../organization.types"; + +type SendInvitationEmailParams = { + email: string; + role: InviteMemberInput["role"]; + organization: IOrganization; + invitationToken: string; + ownersName: string; +}; + +export const sendInvitationEmail = async ({ + email, + role, + organization, + invitationToken, + ownersName, +}: SendInvitationEmailParams) => { + const inviteeName = email.split("@")[0] || "User"; + const acceptLink = `${FRONTEND_BASE_URL}/accept-invitation?token=${invitationToken}`; + const verifyAccountLink = `${FRONTEND_BASE_URL}/verify-account`; + + return await sendEmailWithTemplate({ + to: [ + { + email_address: { + address: email, + name: inviteeName, + }, + }, + ], + merge_info: { + role, + organizationName: organization.name, + name: inviteeName, + acceptLink, + verify_account_link: verifyAccountLink, + invitationLinkExpiry: "7 days", + ownersName, + }, + subject: `Invitation to join ${organization.name}`, + mail_template_key: INVITATION_TEMPLATE_KEY, + template_alias: "invitation", + }); +}; From 4d8e2f3707b158fa3955a17776901d8d9bdc2238 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Wed, 7 Jan 2026 06:40:56 +0100 Subject: [PATCH 06/12] create accept invite endpoint --- src/modules/membership/membership.model.ts | 15 +- src/modules/membership/membership.service.ts | 30 ++- src/modules/membership/membership.types.ts | 11 +- .../integration/acceptInvite.v1.test.ts | 187 ++++++++++++++++++ .../integration/inviteMember.v1.test.ts | 2 - .../organization/organization.controller.ts | 95 +++++++++ src/modules/organization/organization.docs.ts | 17 ++ .../organization/organization.service.ts | 9 +- .../organization/organization.types.ts | 15 +- .../organization/organization.validators.ts | 4 + .../routes/organization.v1.routes.ts | 9 + 11 files changed, 378 insertions(+), 16 deletions(-) create mode 100644 src/modules/organization/__tests__/integration/acceptInvite.v1.test.ts diff --git a/src/modules/membership/membership.model.ts b/src/modules/membership/membership.model.ts index 51caaf7..eee8511 100644 --- a/src/modules/membership/membership.model.ts +++ b/src/modules/membership/membership.model.ts @@ -31,9 +31,22 @@ const membershipSchema = new Schema( enum: ["ACTIVE", "DISABLED", "PENDING"], default: "ACTIVE", }, - invitationToken: { + inviteTokenHash: { type: String, default: null, + select: false, + }, + inviteExpiresAt: { + type: Date, + default: null, + }, + acceptedAt: { + type: Date, + default: null, + }, + joinedAt: { + type: Date, + default: null, }, invitedBy: { type: Schema.Types.ObjectId, diff --git a/src/modules/membership/membership.service.ts b/src/modules/membership/membership.service.ts index f106c81..13e4f9c 100644 --- a/src/modules/membership/membership.service.ts +++ b/src/modules/membership/membership.service.ts @@ -19,7 +19,8 @@ const MembershipService = { email?: string; role: string; status: string; - invitationToken?: string; + inviteTokenHash?: string; + inviteExpiresAt?: Date; invitedBy?: string; } = { orgId: input.orgId, @@ -33,8 +34,11 @@ const MembershipService = { membershipData.email = input.email; } - if (input.invitationToken) { - membershipData.invitationToken = input.invitationToken; + if (input.inviteTokenHash) { + membershipData.inviteTokenHash = input.inviteTokenHash; + } + if (input.inviteExpiresAt) { + membershipData.inviteExpiresAt = input.inviteExpiresAt; } if (input.invitedBy) { membershipData.invitedBy = input.invitedBy; @@ -42,9 +46,10 @@ const MembershipService = { const membership = new MembershipModel(membershipData); - if (session) await membership.save({ session }); - else await membership.save(); + await membership.save({ session: session || null }); + if (input.email === "inviteeone@example.com") + console.log(membership, "inviteeone@example.com"); return { success: true, data: { @@ -59,6 +64,16 @@ const MembershipService = { } }, + getPendingMembershipByHash: async ( + input: string, + session?: mongoose.ClientSession | null, + ): Promise => { + return await MembershipModel.findOne({ + inviteTokenHash: input, + status: "PENDING", + }).session(session || null); + }, + getMembershipById: async (id: string): Promise => { return await MembershipModel.findById(id); }, @@ -72,11 +87,12 @@ const MembershipService = { getMembershipByUserAndOrg: async ( userId: string, orgId: string, + options?: { select: string }, ): Promise => { - return await MembershipModel.findOne({ + return MembershipModel.findOne({ userId: new mongoose.Types.ObjectId(userId), orgId: new mongoose.Types.ObjectId(orgId), - }); + }).select(options?.select || ""); }, getMembershipsByOrg: async (orgId: string): Promise => { diff --git a/src/modules/membership/membership.types.ts b/src/modules/membership/membership.types.ts index b2b02e0..15ec29b 100644 --- a/src/modules/membership/membership.types.ts +++ b/src/modules/membership/membership.types.ts @@ -10,7 +10,10 @@ export interface IMembership extends mongoose.Document { email?: string | null; role: MembershipRole; status: MembershipStatus; - invitationToken?: string | null; + inviteTokenHash?: string | null; + inviteExpiresAt?: Date | null; + acceptedAt?: Date | null; + joinedAt?: Date | null; invitedBy?: mongoose.Types.ObjectId | null; createdAt: Date; updatedAt: Date; @@ -22,7 +25,8 @@ export type CreateMembershipInput = { email?: string; role: MembershipRole; status?: MembershipStatus; - invitationToken?: string; + inviteTokenHash?: string; + inviteExpiresAt?: Date; invitedBy?: string; }; @@ -36,6 +40,7 @@ export type MembershipData = { email?: string; role: string; status: string; - invitationToken?: string; + inviteTokenHash?: string; + inviteExpiresAt?: Date; invitedBy?: string; }; diff --git a/src/modules/organization/__tests__/integration/acceptInvite.v1.test.ts b/src/modules/organization/__tests__/integration/acceptInvite.v1.test.ts new file mode 100644 index 0000000..8f70310 --- /dev/null +++ b/src/modules/organization/__tests__/integration/acceptInvite.v1.test.ts @@ -0,0 +1,187 @@ +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 MembershipService from "@modules/membership/membership.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 { hashWithCrypto } from "@utils/encryptors"; +import { CreateOrganizationOutput } from "@modules/organization/organization.types"; +import { ISuccessPayload } from "src/types"; + +const { testPassword } = TEST_CONSTANTS; + +beforeEach(async () => { + await clearDB(); + jest.clearAllMocks(); +}); + +describe("POST /api/v1/org/invite/accept", () => { + let owner: IUser; + let organizationId: string; + let invitee: IUser; + let cookie: string; + let rawToken: string; + let inviteTokenHash: string; + + beforeEach(async () => { + const ownerData = UserFactory.generate(); + owner = await UserService.createUser({ + ...ownerData, + password: testPassword, + }); + owner.isEmailVerified = true; + await owner.save(); + + const orgResult = await OrganizationService.createOrganization(owner, { + name: "Test Org", + size: 10, + }); + if (!orgResult.success) throw new Error("Failed to create org"); + organizationId = (orgResult as ISuccessPayload) + .data.organizationId; + + const inviteeData = UserFactory.generate(); + invitee = await UserService.createUser({ + ...inviteeData, + password: testPassword, + }); + invitee.isEmailVerified = true; + await invitee.save(); + + const accessToken = generateAccessToken({ + id: invitee._id.toString(), + email: invitee.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + + rawToken = "valid-token-123"; + inviteTokenHash = hashWithCrypto(rawToken); + + await MembershipService.createMembership({ + orgId: organizationId, + email: invitee.email, + role: "MEMBER", + status: "PENDING", + inviteTokenHash: inviteTokenHash, + inviteExpiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + invitedBy: owner._id.toString(), + }); + }); + + it("should successfully accept an invitation", async () => { + const res = await request(app) + .post("/api/v1/org/invite/accept") + .set("Cookie", [cookie]) + .send({ token: rawToken }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveProperty("membershipId"); + expect(res.body.data.orgId.toString()).toBe(organizationId); + + const membership = await MembershipService.getMembershipByUserAndOrg( + invitee._id.toString(), + organizationId, + { select: "+inviteTokenHash" }, + ); + + expect(membership).toBeDefined(); + expect(membership?.status).toBe("ACTIVE"); + expect(membership?.userId?.toString()).toBe(invitee._id.toString()); + expect(membership?.inviteTokenHash).toBeNull(); + expect(membership?.inviteExpiresAt).toBeNull(); + expect(membership?.acceptedAt).toBeDefined(); + expect(membership?.joinedAt).toBeDefined(); + }); + + it("should return 404 if token is invalid", async () => { + const res = await request(app) + .post("/api/v1/org/invite/accept") + .set("Cookie", [cookie]) + .send({ token: "invalid-token" }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Invite not found"); + }); + + it("should return 410 if invite is expired", async () => { + const existing = await MembershipService.getMembershipByEmailAndOrg( + invitee.email, + organizationId, + ); + if (existing) { + existing.inviteExpiresAt = new Date(Date.now() - 10000); + await existing.save(); + } + + const res = await request(app) + .post("/api/v1/org/invite/accept") + .set("Cookie", [cookie]) + .send({ token: rawToken }); + + expect(res.status).toBe(410); + expect(res.body.error).toBe("Invite expired"); + }); + + it("should return 403 if email validation fails (mismatch)", async () => { + const otherUser = await UserService.createUser({ + ...UserFactory.generate(), + password: testPassword, + }); + const otherToken = generateAccessToken({ + id: otherUser._id.toString(), + email: otherUser.email, + }); + const otherCookie = createSignedAccessTokenCookie(otherToken); + + const res = await request(app) + .post("/api/v1/org/invite/accept") + .set("Cookie", [otherCookie]) + .send({ token: rawToken }); + + expect(res.status).toBe(403); + expect(res.body.error).toBe("Invite email mismatch"); + }); + + it("should return 409 if user already has an active organization membership", async () => { + const newOwner = await UserService.createUser({ + ...UserFactory.generate(), + password: testPassword, + }); + newOwner.isEmailVerified = true; + await newOwner.save(); + + const otherOrgResult = await OrganizationService.createOrganization( + newOwner, + { + name: "Other Org", + size: 5, + }, + ); + const otherOrgId = ( + otherOrgResult as ISuccessPayload + ).data.organizationId; + + await MembershipService.createMembership({ + orgId: otherOrgId, + userId: invitee._id.toString(), + role: "MEMBER", + status: "ACTIVE", + }); + + const res = await request(app) + .post("/api/v1/org/invite/accept") + .set("Cookie", [cookie]) + .send({ token: rawToken }); + + expect(res.status).toBe(409); + expect(res.body.error).toBe("User already belongs to an organization"); + }); +}); diff --git a/src/modules/organization/__tests__/integration/inviteMember.v1.test.ts b/src/modules/organization/__tests__/integration/inviteMember.v1.test.ts index ac1ab0d..6b4f230 100644 --- a/src/modules/organization/__tests__/integration/inviteMember.v1.test.ts +++ b/src/modules/organization/__tests__/integration/inviteMember.v1.test.ts @@ -658,7 +658,6 @@ describe("POST /api/v1/org/invite", () => { expect(membership?.role).toBe("MANAGER"); expect(membership?.status).toBe("PENDING"); expect(membership?.invitedBy?.toString()).toBe(owner._id.toString()); - expect(membership?.invitationToken).toBeDefined(); }); it("should send email with correct merge info", async () => { @@ -758,7 +757,6 @@ describe("POST /api/v1/org/invite", () => { ); expect(membership).toBeDefined(); expect(membership?.status).toBe("PENDING"); - expect(membership?.invitationToken).toBeDefined(); expect(membership?.invitedBy?.toString()).toBe(owner._id.toString()); }); }); diff --git a/src/modules/organization/organization.controller.ts b/src/modules/organization/organization.controller.ts index ef9e64d..1183173 100644 --- a/src/modules/organization/organization.controller.ts +++ b/src/modules/organization/organization.controller.ts @@ -1,5 +1,8 @@ import { Request, Response, NextFunction } from "express"; +import mongoose from "mongoose"; import OrganizationService from "./organization.service"; +import MembershipService from "@modules/membership/membership.service"; +import { hashWithCrypto } from "@utils/encryptors"; import { CreateOrganizationInput, CreateOrganizationOutput, @@ -12,6 +15,7 @@ import AppError from "@utils/AppError"; import { IErrorPayload, ISuccessPayload } from "src/types"; import { routeTryCatcher } from "@utils/routeTryCatcher"; import { IUser } from "@modules/user/user.types"; +import { IMembership } from "@modules/membership/membership.types"; export const createOrganization = routeTryCatcher( async (req: Request, res: Response, next: NextFunction) => { @@ -139,3 +143,94 @@ export const inviteMember = routeTryCatcher( }); }, ); + +export const acceptInvite = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + const { token } = req.body; + const user = req.user as IUser; + + if (!user) { + await session.abortTransaction(); + session.endSession(); + return next(AppError.unauthorized("Authentication required")); + } + + const existingMemberships = await MembershipService.getMembershipsByUser( + user._id.toString(), + ); + const activeMembership = existingMemberships.find( + (m: IMembership) => m.status === "ACTIVE", + ); + + if (activeMembership) { + await session.abortTransaction(); + session.endSession(); + return next( + AppError.conflict("User already belongs to an organization"), + ); + } + + const inviteTokenHash = hashWithCrypto(token); + + const membership = await MembershipService.getPendingMembershipByHash( + inviteTokenHash, + session, + ); + + if (!membership) { + await session.abortTransaction(); + session.endSession(); + return next(AppError.notFound("Invite not found")); + } + + if ( + membership.inviteExpiresAt && + new Date() > membership.inviteExpiresAt + ) { + await session.abortTransaction(); + session.endSession(); + return next(new AppError("Invite expired", 410)); + } + + if ( + membership.email && + membership.email.toLowerCase() !== user.email.toLowerCase() + ) { + await session.abortTransaction(); + session.endSession(); + return next(AppError.forbidden("Invite email mismatch")); + } + + membership.status = "ACTIVE"; + membership.userId = user._id; + membership.acceptedAt = new Date(); + membership.joinedAt = new Date(); + membership.inviteTokenHash = null; + membership.inviteExpiresAt = null; + + await membership.save({ session }); + + await session.commitTransaction(); + + return res.status(200).json({ + success: true, + message: "Invitation accepted successfully", + data: { + membershipId: membership._id, + orgId: membership.orgId, + }, + }); + } catch (err) { + if (session.inTransaction()) { + await session.abortTransaction(); + } + return next(AppError.internal((err as Error).message)); + } finally { + session.endSession(); + } + }, +); diff --git a/src/modules/organization/organization.docs.ts b/src/modules/organization/organization.docs.ts index 863d018..c2a9b01 100644 --- a/src/modules/organization/organization.docs.ts +++ b/src/modules/organization/organization.docs.ts @@ -6,6 +6,8 @@ import { GetOrganizationMembersOutput, InviteMemberInput, InviteMemberOutput, + AcceptInviteInput, + AcceptInviteOutput, } from "./organization.types"; import { ISuccessPayload, IErrorPayload } from "src/types"; @@ -57,5 +59,20 @@ export type OrganizationApiSpec = Tspec.DefineApiSpec<{ }; }; }; + "/invite/accept": { + post: { + summary: "Accept an organization invitation"; + body: AcceptInviteInput; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + 409: IErrorPayload; + 410: IErrorPayload; + }; + }; + }; }; }>; diff --git a/src/modules/organization/organization.service.ts b/src/modules/organization/organization.service.ts index 851017a..c701d23 100644 --- a/src/modules/organization/organization.service.ts +++ b/src/modules/organization/organization.service.ts @@ -14,6 +14,8 @@ import { } from "./organization.types"; import { ISuccessPayload, IErrorPayload } from "src/types"; import { generateRandomTokenWithCrypto } from "@utils/generators"; +import { hashWithCrypto } from "@utils/encryptors"; +import { convertTimeToMilliseconds } from "@utils/index"; import { sendInvitationEmail } from "./utils/invitationEmail"; import { IMembership } from "@modules/membership/membership.types"; @@ -262,12 +264,17 @@ const OrganizationService = { } const invitationToken = generateRandomTokenWithCrypto(32); + const inviteTokenHash = hashWithCrypto(invitationToken); + const inviteExpiresAt = new Date( + Date.now() + convertTimeToMilliseconds(168, "hours"), + ); const membershipData: PendingMembershipData = { orgId: organization._id.toString(), role: input.role, status: "PENDING", - invitationToken, + inviteTokenHash, // Store hash + inviteExpiresAt, // Store expiration invitedBy: inviter._id.toString(), }; diff --git a/src/modules/organization/organization.types.ts b/src/modules/organization/organization.types.ts index 11d7594..fe6666a 100644 --- a/src/modules/organization/organization.types.ts +++ b/src/modules/organization/organization.types.ts @@ -1,6 +1,9 @@ import mongoose from "mongoose"; import { z } from "zod"; -import { createOrganizationSchema } from "./organization.validators"; +import { + createOrganizationSchema, + acceptInviteSchema, +} from "./organization.validators"; type Status = "ACTIVE" | "INACTIVE"; @@ -67,6 +70,13 @@ export type InviteMemberInput = { role: "OWNER" | "MANAGER" | "MEMBER" | "VIEWER"; }; +export type AcceptInviteInput = z.infer; + +export type AcceptInviteOutput = { + membershipId: string; + orgId: string; +}; + export type InviteMemberOutput = { invitationId: string; emailSent: boolean; @@ -83,6 +93,7 @@ export type PendingMembershipData = { email?: string; role: "OWNER" | "MANAGER" | "MEMBER" | "VIEWER"; status: "PENDING"; - invitationToken: string; + inviteTokenHash: string; + inviteExpiresAt: Date; invitedBy: string; }; diff --git a/src/modules/organization/organization.validators.ts b/src/modules/organization/organization.validators.ts index 408f38b..0e95d20 100644 --- a/src/modules/organization/organization.validators.ts +++ b/src/modules/organization/organization.validators.ts @@ -20,3 +20,7 @@ export const inviteMemberSchema = z.object({ errorMap: () => ({ message: "Invalid role" }), }), }); + +export const acceptInviteSchema = z.object({ + token: z.string({ required_error: "Token is required" }), +}); diff --git a/src/modules/organization/routes/organization.v1.routes.ts b/src/modules/organization/routes/organization.v1.routes.ts index a95c8da..7e27e19 100644 --- a/src/modules/organization/routes/organization.v1.routes.ts +++ b/src/modules/organization/routes/organization.v1.routes.ts @@ -5,12 +5,14 @@ import requireRole from "@middlewares/requireRole"; import { createOrganizationSchema, inviteMemberSchema, + acceptInviteSchema, } from "../organization.validators"; import { createOrganization, getOrganization, getOrganizationMembers, inviteMember, + acceptInvite, } from "../organization.controller"; const organizationRouter = Router(); @@ -39,4 +41,11 @@ organizationRouter.post( inviteMember, ); +organizationRouter.post( + "/invite/accept", + authenticate, + validateResource(acceptInviteSchema), + acceptInvite, +); + export default organizationRouter; From b9be82dfeac38ec74fbfbc7e7da978f4c086537c Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Mon, 2 Mar 2026 03:43:05 +0100 Subject: [PATCH 07/12] feat: add security middleware (helmet, rate-limit), cors config and integration tests --- implementation_plan.md | 52 +++++++++++++++++++ package-lock.json | 49 +++++++++++++++++ package.json | 3 ++ prioritized_checklist.md | 40 ++++++++++++++ src/__tests__/integration/cors.v1.test.ts | 23 ++++++++ src/__tests__/integration/health.v1.test.ts | 11 ++++ .../integration/rateLimit.v1.test.ts | 15 ++++++ src/__tests__/integration/security.v1.test.ts | 17 ++++++ src/app.ts | 31 ++++++++++- src/config/env.ts | 2 +- 10 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 implementation_plan.md create mode 100644 prioritized_checklist.md create mode 100644 src/__tests__/integration/cors.v1.test.ts create mode 100644 src/__tests__/integration/health.v1.test.ts create mode 100644 src/__tests__/integration/rateLimit.v1.test.ts create mode 100644 src/__tests__/integration/security.v1.test.ts diff --git a/implementation_plan.md b/implementation_plan.md new file mode 100644 index 0000000..c1fe218 --- /dev/null +++ b/implementation_plan.md @@ -0,0 +1,52 @@ +# Production-Grade Timesheets MVP Implementation Plan + +The current codebase establishes a solid foundation with Auth, Organization, and Membership modules. To reach a "production-grade MVP" similar to Clockify, several core modules and infrastructure enhancements are required. + +## Proposed Changes + +### 1. Core Runtime Modules +New modules to handle the actual timesheet logic. + +#### [NEW] Time Entry Module +- **Model**: Track `description`, `startTime`, `endTime`, `duration`, `billable` status, and links to User, Org, Project, and Task. +- **Features**: Timer start/stop logic, manual entry CRUD, bulk deletes, and basic validation (e.g., end time > start time). + +#### [NEW] Project & Client Modules +- **Project**: Group entries by project. Track `color`, `billableRate`, and `visibility`. +- **Client**: Group projects by client for better organization and billing. + +#### [NEW] Task & Tag Modules +- **Task**: Granular work items within a project. +- **Tag**: Cross-project categorization. + +#### [NEW] Reporting Module +- **Features**: Summary and Detailed reports. +- **Aggregation**: Grouping time entries by Project, User, or Client over specific date ranges. + +### 2. Infrastructure & Security +Enhancements to make the backend production-ready. + +#### [MODIFY] Security & Middleware +- **Helmet**: Add `helmet` for secure HTTP headers. +- **Rate Limiting**: Implement `express-rate-limit` to prevent brute-force and DDoS. +- **CORS**: Tighten CORS policy for production environments. + +#### [MODIFY] Observability +- **Structured Logging**: Replace/Supplement `morgan` with `pino` or `winston` for JSON logging. +- **Health Checks**: Add `/health` endpoint for monitoring and orchestration. + +#### [NEW] DevOps +- **Docker**: Add `Dockerfile` and `docker-compose.yml` for consistent development and deployment. + +## Verification Plan + +### Automated Tests +- **Unit Tests**: For each new service (TimeEntry, Project, etc.). +- **Integration Tests**: End-to-end flows like: + 1. User signs up -> Creates Org -> Creates Project -> Starts Timer -> Stops Timer. + 2. Owner invites member -> Member joins -> Member logs time -> Owner views report. +- **Command**: `npm run test` (or `yarn test`). + +### Manual Verification +1. **API Testing**: Use Postman/Insomnia to verify new endpoints against `openapi.json` specs. +2. **Email Flow**: Verify invitation and password reset emails in a staging/dev environment. diff --git a/package-lock.json b/package-lock.json index 4daf452..cd03d2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,8 @@ "cors": "^2.8.5", "dotenv": "^16.6.1", "express": "^4.19.2", + "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "mongoose": "^8.5.0", "morgan": "^1.10.1", @@ -34,6 +36,7 @@ "@types/cookie-signature": "^1.1.2", "@types/cors": "^2.8.19", "@types/express": "^4.17.21", + "@types/helmet": "^0.0.48", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/supertest": "^6.0.3", @@ -1867,6 +1870,16 @@ "@types/send": "*" } }, + "node_modules/@types/helmet": { + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/@types/helmet/-/helmet-0.0.48.tgz", + "integrity": "sha512-C7MpnvSDrunS1q2Oy1VWCY7CDWHozqSnM8P4tFeRTuzwqni+PYOjEredwcqWG+kLpYcgLsgcY3orHB54gbx2Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -4117,6 +4130,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/cookie": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", @@ -4763,6 +4794,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -4950,6 +4990,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index 77045b4..a56d141 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "cors": "^2.8.5", "dotenv": "^16.6.1", "express": "^4.19.2", + "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "mongoose": "^8.5.0", "morgan": "^1.10.1", @@ -41,6 +43,7 @@ "@types/cookie-signature": "^1.1.2", "@types/cors": "^2.8.19", "@types/express": "^4.17.21", + "@types/helmet": "^0.0.48", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/supertest": "^6.0.3", diff --git a/prioritized_checklist.md b/prioritized_checklist.md new file mode 100644 index 0000000..5f7d656 --- /dev/null +++ b/prioritized_checklist.md @@ -0,0 +1,40 @@ +# Implementation Checklist (Easiest to Most Complex) + +This checklist breaks down the [Implementation Plan](file:///Users/exploit/Desktop/projects/timesheets/timesheets-backend/implementation_plan.md) into actionable steps, starting with low-hanging fruit. + +### Phase 1: Infrastructure & Security (Quick Wins) +- [x] **Health Check Endpoint**: Add a simple GET `/health` route to verify server status. +- [x] **Security Headers**: Install and configure `helmet` middleware. +- [x] **CORS Configuration**: Restrict CORS to allowed origins for production. +- [x] **Rate Limiting**: Add `express-rate-limit` to sensitive routes (Auth, Invitations). + +### Phase 2: Metadata Modules (Foundation) +- [ ] **Tag Module**: Implement basic CRUD (Create, Read, Update, Delete) for tags. +- [ ] **Client Module**: Implement CRUD for clients (Name, Address, Currency). +- [ ] **Project Module**: Implement CRUD for projects (Name, Color, Billable status, link to Client). +- [ ] **Task Module**: Implement CRUD for tasks within projects. + +### Phase 3: Core Logic (The Meat) +- [ ] **Time Entry Model**: Define the Mongoose schema for time entries. +- [ ] **Manual Time Entry**: Multi-step implementation: + - [ ] Create manual time entry (POST). + - [ ] List user time entries (GET). + - [ ] Update/Delete entry (PATCH/DELETE). +- [ ] **Timer Implementation**: + - [ ] Start timer (POST - sets `startTime`, `endTime` is null). + - [ ] Stop timer (PATCH - calculates `duration` and sets `endTime`). + - [ ] Prevent multiple active timers per user. + +### Phase 4: Observability & DevOps +- [ ] **Structured Logging**: Integrate `pino` or `winston` and replace existing `console.log` calls. +- [ ] **Dockerization**: Create a `Dockerfile` and `docker-compose.yml` with MongoDB environment setup. + +### Phase 5: Advanced Features +- [ ] **Reporting Engine**: + - [ ] Implement data aggregation by Project. + - [ ] Implement data aggregation by User/Member. + - [ ] Export reports (CSV/JSON). + +### Phase 6: Polish & Verification +- [ ] **Expand Test Coverage**: Add integration tests for complete end-to-end flows. +- [ ] **Documentation**: Ensure `openapi.json` is updated with all new endpoints. diff --git a/src/__tests__/integration/cors.v1.test.ts b/src/__tests__/integration/cors.v1.test.ts new file mode 100644 index 0000000..7915c83 --- /dev/null +++ b/src/__tests__/integration/cors.v1.test.ts @@ -0,0 +1,23 @@ +import request from "supertest"; +import app from "@app"; + +describe("CORS Configuration", () => { + const allowedOrigin = "http://localhost:3000"; + const disallowedOrigin = "http://malicious-site.com"; + + it("should allow requests from an allowed origin", async () => { + const res = await request(app) + .get("/api/health") + .set("Origin", allowedOrigin); + + expect(res.headers["access-control-allow-origin"]).toBe(allowedOrigin); + }); + + it("should NOT allow requests from unauthorized origins", async () => { + const res = await request(app) + .get("/api/health") + .set("Origin", disallowedOrigin); + + expect(res.headers["access-control-allow-origin"]).toBeUndefined(); + }); +}); diff --git a/src/__tests__/integration/health.v1.test.ts b/src/__tests__/integration/health.v1.test.ts new file mode 100644 index 0000000..a19d4ce --- /dev/null +++ b/src/__tests__/integration/health.v1.test.ts @@ -0,0 +1,11 @@ +import request from "supertest"; +import app from "@app"; + +describe("GET /api/health", () => { + it("should return 200 and status ok", async () => { + const res = await request(app).get("/api/health"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: "ok" }); + }); +}); diff --git a/src/__tests__/integration/rateLimit.v1.test.ts b/src/__tests__/integration/rateLimit.v1.test.ts new file mode 100644 index 0000000..0421911 --- /dev/null +++ b/src/__tests__/integration/rateLimit.v1.test.ts @@ -0,0 +1,15 @@ +import request from "supertest"; +import app from "@app"; + +describe("Rate Limiting", () => { + it("should return 429 when rate limit is exceeded", async () => { + const requests = Array.from({ length: 110 }, () => + request(app).get("/api/health"), + ); + + const responses = await Promise.all(requests); + const tooManyRequests = responses.filter((res) => res.status === 429); + + expect(tooManyRequests.length).toBeGreaterThan(0); + }); +}); diff --git a/src/__tests__/integration/security.v1.test.ts b/src/__tests__/integration/security.v1.test.ts new file mode 100644 index 0000000..030cfde --- /dev/null +++ b/src/__tests__/integration/security.v1.test.ts @@ -0,0 +1,17 @@ +import request from "supertest"; +import app from "@app"; + +describe("Security Headers", () => { + it("should have various security headers set by helmet", async () => { + const res = await request(app).get("/api/health"); + + expect(res.headers["x-content-type-options"]).toBe("nosniff"); + expect(res.headers["x-dns-prefetch-control"]).toBe("off"); + expect(res.headers["expect-ct"]).toBeUndefined(); // This one might vary depending on helmet version + expect(res.headers["x-frame-options"]).toBe("SAMEORIGIN"); + expect(res.headers["strict-transport-security"]).toBeDefined(); + expect(res.headers["x-download-options"]).toBe("noopen"); + expect(res.headers["x-permitted-cross-domain-policies"]).toBe("none"); + expect(res.headers["referrer-policy"]).toBe("no-referrer"); + }); +}); diff --git a/src/app.ts b/src/app.ts index 3d741c0..764fa90 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,15 +1,42 @@ import express, { Application, NextFunction, Request, Response } from "express"; +import helmet from "helmet"; +import { rateLimit } from "express-rate-limit"; import cors from "cors"; import morgan from "morgan"; import v1Router from "./routes/v1.route"; import errorHandler from "./middlewares/errorHandler"; import cookieParser from "cookie-parser"; -import { COOKIE_SECRET } from "@config/env"; +import { COOKIE_SECRET, FRONTEND_BASE_URL } from "@config/env"; import swaggerUi from "swagger-ui-express"; + const app: Application = express(); +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 100, + standardHeaders: "draft-7", + legacyHeaders: false, + message: { + success: false, + error: "Too many requests, please try again later.", + }, +}); + +app.use(helmet()); +app.use(limiter); app.set("trust proxy", 1); -app.use(cors()); +app.use( + cors({ + origin: (origin, callback) => { + if (!origin || origin === FRONTEND_BASE_URL) { + callback(null, true); + } else { + callback(null, false); + } + }, + credentials: true, + }), +); app.use(cookieParser(COOKIE_SECRET)); app.use(express.json()); app.use(morgan("dev")); diff --git a/src/config/env.ts b/src/config/env.ts index ff6de1f..ba8379b 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -40,7 +40,7 @@ const env = isTest EMAIL_VERIFICATION_TEMPLATE_KEY: "", PASSWORD_RESET_TEMPLATE_KEY: "", INVITATION_TEMPLATE_KEY: "", - FRONTEND_BASE_URL: "", + FRONTEND_BASE_URL: "http://localhost:3000", }, error: envSchema.safeParse(process.env).error, } From 4fccdc556f29673042c6c243e7317bc3989ab664 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Tue, 3 Mar 2026 06:32:17 +0100 Subject: [PATCH 08/12] feat: implement time entry module, standardize ID mapping, and strengthen type safety --- jest.config.mjs | 1 + prioritized_checklist.md | 26 +- src/constants/index.ts | 37 +++ src/docs/health.docs.ts | 16 + src/middlewares/requireRole.ts | 19 +- .../__tests__/integration/client.v1.test.ts | 207 +++++++++++++ src/modules/client/client.controller.ts | 105 +++++++ src/modules/client/client.docs.ts | 59 ++++ src/modules/client/client.model.ts | 45 +++ src/modules/client/client.service.ts | 125 ++++++++ src/modules/client/client.types.ts | 44 +++ src/modules/client/client.validators.ts | 21 ++ src/modules/client/routes/client.v1.routes.ts | 41 +++ src/modules/membership/membership.model.ts | 7 +- src/modules/membership/membership.types.ts | 8 +- .../membership/membership.validators.ts | 7 +- .../organization/organization.model.ts | 5 +- .../organization/organization.types.ts | 17 +- .../routes/organization.v1.routes.ts | 6 +- .../__tests__/integration/project.v1.test.ts | 108 +++++++ src/modules/project/project.controller.ts | 105 +++++++ src/modules/project/project.docs.ts | 59 ++++ src/modules/project/project.model.ts | 46 +++ src/modules/project/project.service.ts | 133 +++++++++ src/modules/project/project.types.ts | 44 +++ src/modules/project/project.validators.ts | 27 ++ .../project/routes/project.v1.routes.ts | 44 +++ .../tag/__tests__/integration/tag.v1.test.ts | 233 +++++++++++++++ src/modules/tag/routes/tag.v1.routes.ts | 41 +++ src/modules/tag/tag.controller.ts | 91 ++++++ src/modules/tag/tag.docs.ts | 55 ++++ src/modules/tag/tag.model.ts | 37 +++ src/modules/tag/tag.service.ts | 127 ++++++++ src/modules/tag/tag.types.ts | 36 +++ src/modules/tag/tag.validators.ts | 23 ++ .../__tests__/integration/task.v1.test.ts | 130 +++++++++ src/modules/task/routes/task.v1.routes.ts | 41 +++ src/modules/task/task.controller.ts | 109 +++++++ src/modules/task/task.docs.ts | 55 ++++ src/modules/task/task.model.ts | 42 +++ src/modules/task/task.service.ts | 130 +++++++++ src/modules/task/task.types.ts | 40 +++ src/modules/task/task.validators.ts | 19 ++ .../integration/time-entry.v1.test.ts | 198 +++++++++++++ .../time-entry/routes/time-entry.v1.routes.ts | 53 ++++ .../time-entry/time-entry.controller.ts | 169 +++++++++++ src/modules/time-entry/time-entry.docs.ts | 94 ++++++ src/modules/time-entry/time-entry.model.ts | 80 +++++ src/modules/time-entry/time-entry.service.ts | 273 ++++++++++++++++++ src/modules/time-entry/time-entry.types.ts | 66 +++++ .../time-entry/time-entry.validators.ts | 57 ++++ src/modules/user/user.model.ts | 5 + src/modules/user/user.types.ts | 1 + src/routes/v1.route.ts | 10 + src/tests/helpers/seed.ts | 47 ++- src/types/express.d.ts | 4 + src/types/index.ts | 6 + src/utils/validators.ts | 11 + tsconfig.json | 3 +- 59 files changed, 3599 insertions(+), 49 deletions(-) create mode 100644 src/constants/index.ts create mode 100644 src/docs/health.docs.ts create mode 100644 src/modules/client/__tests__/integration/client.v1.test.ts create mode 100644 src/modules/client/client.controller.ts create mode 100644 src/modules/client/client.docs.ts create mode 100644 src/modules/client/client.model.ts create mode 100644 src/modules/client/client.service.ts create mode 100644 src/modules/client/client.types.ts create mode 100644 src/modules/client/client.validators.ts create mode 100644 src/modules/client/routes/client.v1.routes.ts create mode 100644 src/modules/project/__tests__/integration/project.v1.test.ts create mode 100644 src/modules/project/project.controller.ts create mode 100644 src/modules/project/project.docs.ts create mode 100644 src/modules/project/project.model.ts create mode 100644 src/modules/project/project.service.ts create mode 100644 src/modules/project/project.types.ts create mode 100644 src/modules/project/project.validators.ts create mode 100644 src/modules/project/routes/project.v1.routes.ts create mode 100644 src/modules/tag/__tests__/integration/tag.v1.test.ts create mode 100644 src/modules/tag/routes/tag.v1.routes.ts create mode 100644 src/modules/tag/tag.controller.ts create mode 100644 src/modules/tag/tag.docs.ts create mode 100644 src/modules/tag/tag.model.ts create mode 100644 src/modules/tag/tag.service.ts create mode 100644 src/modules/tag/tag.types.ts create mode 100644 src/modules/tag/tag.validators.ts create mode 100644 src/modules/task/__tests__/integration/task.v1.test.ts create mode 100644 src/modules/task/routes/task.v1.routes.ts create mode 100644 src/modules/task/task.controller.ts create mode 100644 src/modules/task/task.docs.ts create mode 100644 src/modules/task/task.model.ts create mode 100644 src/modules/task/task.service.ts create mode 100644 src/modules/task/task.types.ts create mode 100644 src/modules/task/task.validators.ts create mode 100644 src/modules/time-entry/__tests__/integration/time-entry.v1.test.ts create mode 100644 src/modules/time-entry/routes/time-entry.v1.routes.ts create mode 100644 src/modules/time-entry/time-entry.controller.ts create mode 100644 src/modules/time-entry/time-entry.docs.ts create mode 100644 src/modules/time-entry/time-entry.model.ts create mode 100644 src/modules/time-entry/time-entry.service.ts create mode 100644 src/modules/time-entry/time-entry.types.ts create mode 100644 src/modules/time-entry/time-entry.validators.ts create mode 100644 src/utils/validators.ts diff --git a/jest.config.mjs b/jest.config.mjs index c05de4e..30251b0 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -20,5 +20,6 @@ export default { "^@middlewares/(.*)$": "/src/middlewares/$1", "^@docs/(.*)$": "/src/docs/$1", "^@services/(.*)$": "/src/services/$1", + "^@constants$": "/src/constants/index.ts", }, }; diff --git a/prioritized_checklist.md b/prioritized_checklist.md index 5f7d656..f6fe02d 100644 --- a/prioritized_checklist.md +++ b/prioritized_checklist.md @@ -9,21 +9,21 @@ This checklist breaks down the [Implementation Plan](file:///Users/exploit/Deskt - [x] **Rate Limiting**: Add `express-rate-limit` to sensitive routes (Auth, Invitations). ### Phase 2: Metadata Modules (Foundation) -- [ ] **Tag Module**: Implement basic CRUD (Create, Read, Update, Delete) for tags. -- [ ] **Client Module**: Implement CRUD for clients (Name, Address, Currency). -- [ ] **Project Module**: Implement CRUD for projects (Name, Color, Billable status, link to Client). -- [ ] **Task Module**: Implement CRUD for tasks within projects. +- [x] **Tag Module**: Implement basic CRUD (Create, Read, Update, Delete) for tags. +- [x] **Client Module**: Implement CRUD for clients (name, email, address, etc.). +- [x] **Project Module**: Implement CRUD for projects (Name, Color, Billable status, link to Client). +- [x] **Task Module**: Implement CRUD for tasks within projects. ### Phase 3: Core Logic (The Meat) -- [ ] **Time Entry Model**: Define the Mongoose schema for time entries. -- [ ] **Manual Time Entry**: Multi-step implementation: - - [ ] Create manual time entry (POST). - - [ ] List user time entries (GET). - - [ ] Update/Delete entry (PATCH/DELETE). -- [ ] **Timer Implementation**: - - [ ] Start timer (POST - sets `startTime`, `endTime` is null). - - [ ] Stop timer (PATCH - calculates `duration` and sets `endTime`). - - [ ] Prevent multiple active timers per user. +- [x] **Time Entry Model**: Define the Mongoose schema for time entries. +- [x] **Manual Time Entry**: Multi-step implementation: + - [x] Create manual time entry (POST). + - [x] List user time entries (GET). + - [x] Update/Delete entry (PATCH/DELETE). +- [x] **Timer Implementation**: + - [x] Start timer (POST - sets `startTime`, `endTime` is null). + - [x] Stop timer (PATCH - calculates `duration` and sets `endTime`). + - [x] Prevent multiple active timers per user. ### Phase 4: Observability & DevOps - [ ] **Structured Logging**: Integrate `pino` or `winston` and replace existing `console.log` calls. diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..d821ca0 --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,37 @@ +export const USER_ROLES = { + OWNER: "OWNER", + MANAGER: "MANAGER", + MEMBER: "MEMBER", + VIEWER: "VIEWER", +} as const; + +export const TASK_STATUS = { + TODO: "TODO", + IN_PROGRESS: "IN_PROGRESS", + DONE: "DONE", + ARCHIVED: "ARCHIVED", + ACTIVE: "ACTIVE", // Legacy default +} as const; + +export const GLOBAL_STATUS = { + ACTIVE: "ACTIVE", + ARCHIVED: "ARCHIVED", +} as const; + +export const ORG_STATUS = { + ACTIVE: "ACTIVE", + INACTIVE: "INACTIVE", +} as const; + +export const MEMBERSHIP_STATUS = { + ACTIVE: "ACTIVE", + DISABLED: "DISABLED", + PENDING: "PENDING", +} as const; + +export type UserRole = (typeof USER_ROLES)[keyof typeof USER_ROLES]; +export type TaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS]; +export type GlobalStatus = (typeof GLOBAL_STATUS)[keyof typeof GLOBAL_STATUS]; +export type OrgStatus = (typeof ORG_STATUS)[keyof typeof ORG_STATUS]; +export type MembershipStatus = + (typeof MEMBERSHIP_STATUS)[keyof typeof MEMBERSHIP_STATUS]; diff --git a/src/docs/health.docs.ts b/src/docs/health.docs.ts new file mode 100644 index 0000000..10a0fa6 --- /dev/null +++ b/src/docs/health.docs.ts @@ -0,0 +1,16 @@ +import { Tspec } from "tspec"; + +export type HealthApiSpec = Tspec.DefineApiSpec<{ + basePath: "/api"; + tags: ["Health"]; + paths: { + "/health": { + get: { + summary: "Check API health status"; + responses: { + 200: { status: "ok" }; + }; + }; + }; + }; +}>; diff --git a/src/middlewares/requireRole.ts b/src/middlewares/requireRole.ts index 627f7d1..68f9fb7 100644 --- a/src/middlewares/requireRole.ts +++ b/src/middlewares/requireRole.ts @@ -2,11 +2,11 @@ import { Request, Response, NextFunction } from "express"; import AppError from "@utils/AppError"; import { IUser } from "@modules/user/user.types"; import OrganizationService from "@modules/organization/organization.service"; -import { MembershipRole } from "@modules/membership/membership.types"; +import { UserRole } from "@constants"; import { ISuccessPayload } from "src/types"; import { IOrganization } from "@modules/organization/organization.types"; -const requireRole = (allowedRoles: MembershipRole[]) => { +const requireRole = (allowedRoles: UserRole[]) => { return async (req: Request, _res: Response, next: NextFunction) => { const user = req.user as IUser; @@ -22,21 +22,20 @@ const requireRole = (allowedRoles: MembershipRole[]) => { return next(AppError.notFound("User does not have an organization")); } - const userRole = ( - orgResult as ISuccessPayload<{ - organization: IOrganization; - role: MembershipRole; - }> - ).data.role; + const successfulOrgResult = orgResult as ISuccessPayload<{ + organization: IOrganization; + role: UserRole; + }>; - if (!allowedRoles.includes(userRole)) { + if (!allowedRoles.includes(successfulOrgResult.data.role)) { return next( AppError.forbidden( `Access denied. Required roles: ${allowedRoles.join(", ")}`, ), ); } - + req.userOrg = successfulOrgResult.data.organization; + req.userRole = successfulOrgResult.data.role; next(); }; }; diff --git a/src/modules/client/__tests__/integration/client.v1.test.ts b/src/modules/client/__tests__/integration/client.v1.test.ts new file mode 100644 index 0000000..20f0552 --- /dev/null +++ b/src/modules/client/__tests__/integration/client.v1.test.ts @@ -0,0 +1,207 @@ +import request from "supertest"; +import app from "@app"; +import { clearDB } from "@tests/utils"; +import { seedOneUserWithOrg, seedUserInOrg } from "@tests/helpers/seed"; +import { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "../../../auth/__tests__/helpers/testHelpers"; +import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; + +const { verifiedUserEmail } = TEST_CONSTANTS; + +describe("Client Module Integration Tests", () => { + let ownerToken: string; + let managerToken: string; + let memberToken: string; + let viewerToken: string; + let orgId: string; + + beforeEach(async () => { + await clearDB(); + + // Seed owner and organization + const { user: owner, organization } = await seedOneUserWithOrg( + { + email: verifiedUserEmail, + isEmailVerified: true, + }, + {}, + "OWNER", + ); + orgId = organization._id.toString(); + ownerToken = createSignedAccessTokenCookie( + generateAccessToken({ id: owner._id.toString(), email: owner.email }), + ); + + // Seed manager + const { user: manager } = await seedUserInOrg( + orgId, + { email: "manager@example.com" }, + "MANAGER", + ); + managerToken = createSignedAccessTokenCookie( + generateAccessToken({ id: manager._id.toString(), email: manager.email }), + ); + + // Seed member + const { user: member } = await seedUserInOrg( + orgId, + { email: "member@example.com" }, + "MEMBER", + ); + memberToken = createSignedAccessTokenCookie( + generateAccessToken({ id: member._id.toString(), email: member.email }), + ); + + // Seed viewer + const { user: viewer } = await seedUserInOrg( + orgId, + { email: "viewer@example.com" }, + "VIEWER", + ); + viewerToken = createSignedAccessTokenCookie( + generateAccessToken({ id: viewer._id.toString(), email: viewer.email }), + ); + }); + + describe("POST /api/v1/clients", () => { + const clientData = { + name: "Acme Corp", + email: "billing@acme.com", + currency: "USD", + orgId: "", // Will be set in tests + }; + + it("should allow OWNER to create a new client", async () => { + const res = await request(app) + .post("/api/v1/clients") + .set("Cookie", [ownerToken]) + .send({ ...clientData, orgId }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + }); + + it("should allow MANAGER to create a new client", async () => { + const res = await request(app) + .post("/api/v1/clients") + .set("Cookie", [managerToken]) + .send({ ...clientData, orgId }); + + expect(res.status).toBe(201); + }); + + it("should deny MEMBER from creating a client", async () => { + const res = await request(app) + .post("/api/v1/clients") + .set("Cookie", [memberToken]) + .send({ ...clientData, orgId }); + + expect(res.status).toBe(403); + }); + + it("should deny VIEWER from creating a client", async () => { + const res = await request(app) + .post("/api/v1/clients") + .set("Cookie", [viewerToken]) + .send({ ...clientData, orgId }); + + expect(res.status).toBe(403); + }); + + it("should return 400 if name is missing", async () => { + const res = await request(app) + .post("/api/v1/clients") + .set("Cookie", [ownerToken]) + .send({ orgId }); + + expect(res.status).toBe(400); + }); + }); + + describe("GET /api/v1/clients", () => { + beforeEach(async () => { + const ClientModel = (await import("@modules/client/client.model")) + .default; + await new ClientModel({ name: "Client 1", orgId }).save(); + }); + + it("should allow MEMBER to get all clients for organization", async () => { + const res = await request(app) + .get("/api/v1/clients") + .set("Cookie", [memberToken]); + + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(1); + }); + + it("should allow VIEWER to get all clients for organization", async () => { + const res = await request(app) + .get("/api/v1/clients") + .set("Cookie", [viewerToken]); + + expect(res.status).toBe(200); + }); + }); + + describe("PATCH /api/v1/clients/:id", () => { + let clientId: string; + + beforeEach(async () => { + const ClientModel = (await import("@modules/client/client.model")) + .default; + const client = await new ClientModel({ + name: "Old Client", + orgId, + }).save(); + clientId = client.id.toString(); + }); + + it("should allow MANAGER to update client info", async () => { + const res = await request(app) + .patch(`/api/v1/clients/${clientId}`) + .set("Cookie", [managerToken]) + .send({ name: "New Client" }); + + expect(res.status).toBe(200); + expect(res.body.data.name).toBe("New Client"); + }); + + it("should deny MEMBER from updating client info", async () => { + const res = await request(app) + .patch(`/api/v1/clients/${clientId}`) + .set("Cookie", [memberToken]) + .send({ name: "Unauthorized Update" }); + + expect(res.status).toBe(403); + }); + }); + + describe("DELETE /api/v1/clients/:id", () => { + let clientId: string; + + beforeEach(async () => { + const ClientModel = (await import("@modules/client/client.model")) + .default; + const client = await new ClientModel({ name: "Delete Me", orgId }).save(); + clientId = client.id.toString(); + }); + + it("should allow OWNER to delete a client", async () => { + const res = await request(app) + .delete(`/api/v1/clients/${clientId}`) + .set("Cookie", [ownerToken]); + + expect(res.status).toBe(200); + }); + + it("should deny MEMBER from deleting a client", async () => { + const res = await request(app) + .delete(`/api/v1/clients/${clientId}`) + .set("Cookie", [memberToken]); + + expect(res.status).toBe(403); + }); + }); +}); diff --git a/src/modules/client/client.controller.ts b/src/modules/client/client.controller.ts new file mode 100644 index 0000000..b8f0e98 --- /dev/null +++ b/src/modules/client/client.controller.ts @@ -0,0 +1,105 @@ +import { Request, Response } from "express"; +import ClientService from "./client.service"; +import AppError from "@utils/AppError"; +import { IErrorPayload } from "src/types"; +import { routeTryCatcher } from "@utils/routeTryCatcher"; + +export const createClient = routeTryCatcher( + async (req: Request, res: Response) => { + const { name, email, address, currency, orgId } = req.body; + const userOrg = req.userOrg!; + + if (userOrg._id.toString() !== orgId) { + throw AppError.forbidden( + "You can only create clients for your own organization", + ); + } + + const result = await ClientService.createClient({ + name, + email, + address, + currency, + orgId, + }); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(201).json(result); + }, +); + +export const getClients = routeTryCatcher( + async (req: Request, res: Response) => { + const userOrg = req.userOrg!; + const clients = await ClientService.getClientsByOrgId( + userOrg._id.toString(), + ); + + res.status(200).json({ + success: true, + data: clients, + }); + }, +); + +export const updateClient = routeTryCatcher( + async (req: Request, res: Response) => { + const { id } = req.params; + const userOrg = req.userOrg!; + + if (!id) throw AppError.badRequest("Client ID is required"); + + const client = await ClientService.getClientById(id); + if (!client) throw AppError.notFound("Client not found"); + + if (userOrg._id.toString() !== client.orgId.toString()) { + throw AppError.forbidden( + "You do not have permission to update this client", + ); + } + + const result = await ClientService.updateClient(id, req.body); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(200).json(result); + }, +); + +export const deleteClient = routeTryCatcher( + async (req: Request, res: Response) => { + const { id } = req.params; + const userOrg = req.userOrg!; + + if (!id) throw AppError.badRequest("Client ID is required"); + + const client = await ClientService.getClientById(id); + if (!client) throw AppError.notFound("Client not found"); + + if (userOrg._id.toString() !== client.orgId.toString()) { + throw AppError.forbidden( + "You do not have permission to delete this client", + ); + } + + const result = await ClientService.deleteClient(id); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(200).json(result); + }, +); + +export default { + createClient, + getClients, + updateClient, + deleteClient, +}; diff --git a/src/modules/client/client.docs.ts b/src/modules/client/client.docs.ts new file mode 100644 index 0000000..ca5ab9e --- /dev/null +++ b/src/modules/client/client.docs.ts @@ -0,0 +1,59 @@ +import { Tspec } from "tspec"; +import { + CreateClientInput, + UpdateClientInput, + ClientOutput, +} from "./client.types"; +import { ISuccessPayload, IErrorPayload } from "src/types"; + +export type ClientApiSpec = Tspec.DefineApiSpec<{ + basePath: "/api/v1/clients"; + tags: ["Clients"]; + paths: { + "/": { + post: { + summary: "Create a new client"; + body: CreateClientInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + }; + }; + get: { + summary: "Get all clients for the organization"; + responses: { + 200: ISuccessPayload; + 401: IErrorPayload; + 403: IErrorPayload; + }; + }; + }; + "/{id}": { + patch: { + summary: "Update a client"; + path: { id: string }; + body: UpdateClientInput; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + delete: { + summary: "Delete a client"; + path: { id: string }; + responses: { + 200: ISuccessPayload<{ message: string }>; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + }; + }; +}>; diff --git a/src/modules/client/client.model.ts b/src/modules/client/client.model.ts new file mode 100644 index 0000000..30bd1b4 --- /dev/null +++ b/src/modules/client/client.model.ts @@ -0,0 +1,45 @@ +import mongoose, { Schema } from "mongoose"; +import { IClient } from "./client.types"; +import { GLOBAL_STATUS } from "@constants"; + +const clientSchema: Schema = new Schema( + { + name: { + type: String, + required: true, + trim: true, + }, + email: { + type: String, + trim: true, + lowercase: true, + }, + address: { + type: String, + }, + currency: { + type: String, + default: "USD", + }, + orgId: { + type: Schema.Types.ObjectId, + ref: "Organization", + required: true, + }, + status: { + type: String, + enum: Object.values(GLOBAL_STATUS), + default: GLOBAL_STATUS.ACTIVE, + }, + }, + { + timestamps: true, + }, +); + +// Ensure name is unique within the same organization +clientSchema.index({ name: 1, orgId: 1 }, { unique: true }); + +const ClientModel = mongoose.model("Client", clientSchema); + +export default ClientModel; diff --git a/src/modules/client/client.service.ts b/src/modules/client/client.service.ts new file mode 100644 index 0000000..a018926 --- /dev/null +++ b/src/modules/client/client.service.ts @@ -0,0 +1,125 @@ +import { + CreateClientInput, + UpdateClientInput, + ClientOutput, + ClientBase, +} from "./client.types"; +import ClientModel from "./client.model"; +import { ISuccessPayload, IErrorPayload, Mappable } from "src/types"; + +const mapToOutput = (doc: ClientBase & Mappable): ClientOutput => { + return { + id: doc._id.toString(), + name: doc.name, + email: doc.email ?? undefined, + address: doc.address ?? undefined, + currency: doc.currency, + orgId: doc.orgId.toString(), + status: doc.status, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; +}; + +const ClientService = { + createClient: async ( + input: CreateClientInput, + ): Promise | IErrorPayload> => { + try { + const existingClient = await ClientModel.findOne({ + name: input.name, + orgId: input.orgId, + }); + + if (existingClient) { + return { + success: false, + error: "Client with this name already exists in the organization", + }; + } + + const client = new ClientModel(input); + await client.save(); + + return { + success: true, + data: mapToOutput(client as unknown as ClientBase & Mappable), + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + getClientsByOrgId: async (orgId: string): Promise => { + const clients = await ClientModel.find({ orgId, status: "ACTIVE" }); + return clients.map((doc) => + mapToOutput(doc as unknown as ClientBase & Mappable), + ); + }, + + getClientById: async (id: string): Promise => { + const client = await ClientModel.findById(id); + return client + ? mapToOutput(client as unknown as ClientBase & Mappable) + : null; + }, + + updateClient: async ( + id: string, + input: UpdateClientInput, + ): Promise | IErrorPayload> => { + try { + const client = await ClientModel.findById(id); + if (!client) { + return { + success: false, + error: "Client not found", + }; + } + + Object.assign(client, input); + await client.save(); + + return { + success: true, + data: mapToOutput(client as unknown as ClientBase & Mappable), + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + deleteClient: async ( + id: string, + ): Promise | IErrorPayload> => { + try { + const client = await ClientModel.findById(id); + if (!client) { + return { + success: false, + error: "Client not found", + }; + } + + await ClientModel.findByIdAndDelete(id); + + return { + success: true, + data: { message: "Client deleted successfully" }, + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, +}; + +export default ClientService; diff --git a/src/modules/client/client.types.ts b/src/modules/client/client.types.ts new file mode 100644 index 0000000..0391923 --- /dev/null +++ b/src/modules/client/client.types.ts @@ -0,0 +1,44 @@ +import mongoose from "mongoose"; +import { GlobalStatus } from "@constants"; + +export interface ClientBase { + name: string; + email?: string | undefined; + address?: string | undefined; + currency: string; + orgId: mongoose.Types.ObjectId; + status: GlobalStatus; +} + +export interface IClient extends ClientBase, mongoose.Document { + createdAt: Date; + updatedAt: Date; +} + +export interface ClientOutput { + id: string; + name: string; + email?: string | undefined; + address?: string | undefined; + currency: string; + orgId: string; + status: GlobalStatus; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateClientInput { + name: string; + email?: string; + address?: string; + currency?: string; + orgId: string; +} + +export interface UpdateClientInput { + name?: string; + email?: string; + address?: string; + currency?: string; + status?: GlobalStatus; +} diff --git a/src/modules/client/client.validators.ts b/src/modules/client/client.validators.ts new file mode 100644 index 0000000..8e4f4fc --- /dev/null +++ b/src/modules/client/client.validators.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { GLOBAL_STATUS } from "@constants"; +import { zObjectId } from "@utils/validators"; + +export const createClientSchema = z.object({ + name: z.string().min(1).max(100), + email: z.string().email().optional(), + address: z.string().max(500).optional(), + currency: z.string().min(1).max(10).optional(), + orgId: zObjectId, +}); + +export const updateClientSchema = z.object({ + name: z.string().min(1).max(100).optional(), + email: z.string().email().optional(), + address: z.string().max(500).optional(), + currency: z.string().min(1).max(10).optional(), + status: z + .enum(Object.values(GLOBAL_STATUS) as [string, ...string[]]) + .optional(), +}); diff --git a/src/modules/client/routes/client.v1.routes.ts b/src/modules/client/routes/client.v1.routes.ts new file mode 100644 index 0000000..d9310bd --- /dev/null +++ b/src/modules/client/routes/client.v1.routes.ts @@ -0,0 +1,41 @@ +import { Router } from "express"; +import ClientController from "../client.controller"; +import authenticate from "@middlewares/authenticate"; +import validate from "@middlewares/validators"; +import { createClientSchema, updateClientSchema } from "../client.validators"; +import requireRole from "@middlewares/requireRole"; +import { USER_ROLES } from "@constants"; + +const clientRouter = Router(); + +clientRouter.use(authenticate); + +clientRouter.post( + "/", + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + validate(createClientSchema), + ClientController.createClient, +); +clientRouter.get( + "/", + requireRole([ + USER_ROLES.OWNER, + USER_ROLES.MANAGER, + USER_ROLES.MEMBER, + USER_ROLES.VIEWER, + ]), + ClientController.getClients, +); +clientRouter.patch( + "/:id", + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + validate(updateClientSchema), + ClientController.updateClient, +); +clientRouter.delete( + "/:id", + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + ClientController.deleteClient, +); + +export default clientRouter; diff --git a/src/modules/membership/membership.model.ts b/src/modules/membership/membership.model.ts index eee8511..60e2db6 100644 --- a/src/modules/membership/membership.model.ts +++ b/src/modules/membership/membership.model.ts @@ -1,5 +1,6 @@ import mongoose, { Schema } from "mongoose"; import { IMembership } from "./membership.types"; +import { USER_ROLES, MEMBERSHIP_STATUS } from "@constants"; const membershipSchema = new Schema( { @@ -23,13 +24,13 @@ const membershipSchema = new Schema( }, role: { type: String, - enum: ["OWNER", "MANAGER", "MEMBER", "VIEWER"], + enum: Object.values(USER_ROLES), required: true, }, status: { type: String, - enum: ["ACTIVE", "DISABLED", "PENDING"], - default: "ACTIVE", + enum: Object.values(MEMBERSHIP_STATUS), + default: MEMBERSHIP_STATUS.ACTIVE, }, inviteTokenHash: { type: String, diff --git a/src/modules/membership/membership.types.ts b/src/modules/membership/membership.types.ts index 15ec29b..36a5720 100644 --- a/src/modules/membership/membership.types.ts +++ b/src/modules/membership/membership.types.ts @@ -1,14 +1,12 @@ import mongoose from "mongoose"; - -export type MembershipRole = "OWNER" | "MANAGER" | "MEMBER" | "VIEWER"; -export type MembershipStatus = "ACTIVE" | "DISABLED" | "PENDING"; +import { UserRole, MembershipStatus } from "@constants"; export interface IMembership extends mongoose.Document { _id: mongoose.Types.ObjectId; orgId: mongoose.Types.ObjectId; userId?: mongoose.Types.ObjectId | null; email?: string | null; - role: MembershipRole; + role: UserRole; status: MembershipStatus; inviteTokenHash?: string | null; inviteExpiresAt?: Date | null; @@ -23,7 +21,7 @@ export type CreateMembershipInput = { orgId: string; userId?: string; email?: string; - role: MembershipRole; + role: UserRole; status?: MembershipStatus; inviteTokenHash?: string; inviteExpiresAt?: Date; diff --git a/src/modules/membership/membership.validators.ts b/src/modules/membership/membership.validators.ts index dd076af..42f4da5 100644 --- a/src/modules/membership/membership.validators.ts +++ b/src/modules/membership/membership.validators.ts @@ -1,10 +1,13 @@ import { z } from "zod"; +import { USER_ROLES, MEMBERSHIP_STATUS } from "@constants"; export const createMembershipSchema = z.object({ orgId: z.string().min(1, "Organization ID is required"), userId: z.string().min(1, "User ID is required"), - role: z.enum(["OWNER", "MANAGER", "MEMBER", "VIEWER"], { + role: z.enum(Object.values(USER_ROLES) as [string, ...string[]], { errorMap: () => ({ message: "Invalid role" }), }), - status: z.enum(["ACTIVE", "DISABLED", "PENDING"]).optional(), + status: z + .enum(Object.values(MEMBERSHIP_STATUS) as [string, ...string[]]) + .optional(), }); diff --git a/src/modules/organization/organization.model.ts b/src/modules/organization/organization.model.ts index a819d28..6ecf154 100644 --- a/src/modules/organization/organization.model.ts +++ b/src/modules/organization/organization.model.ts @@ -1,6 +1,7 @@ import mongoose, { model } from "mongoose"; import { IOrganization } from "./organization.types"; import { slugify } from "@utils/index"; +import { ORG_STATUS } from "@constants"; const organizationSchema = new mongoose.Schema( { @@ -21,8 +22,8 @@ const organizationSchema = new mongoose.Schema( description: { type: String, trim: true }, status: { type: String, - enum: ["ACTIVE", "INACTIVE"], - default: "ACTIVE", + enum: Object.values(ORG_STATUS), + default: ORG_STATUS.ACTIVE, }, size: { type: Number, diff --git a/src/modules/organization/organization.types.ts b/src/modules/organization/organization.types.ts index fe6666a..a45c380 100644 --- a/src/modules/organization/organization.types.ts +++ b/src/modules/organization/organization.types.ts @@ -4,8 +4,7 @@ import { createOrganizationSchema, acceptInviteSchema, } from "./organization.validators"; - -type Status = "ACTIVE" | "INACTIVE"; +import { OrgStatus, UserRole } from "@constants"; export interface IOrganization extends mongoose.Document { _id: mongoose.Types.ObjectId; @@ -16,7 +15,7 @@ export interface IOrganization extends mongoose.Document { description?: string; createdAt: Date; updatedAt: Date; - status: Status; + status: OrgStatus; size: number; settings: { timezone: string; @@ -38,7 +37,7 @@ export type GetOrganizationOutput = { slug: string; domain?: string; description?: string; - status: string; + status: OrgStatus; size: number; settings: { timezone: string; @@ -47,7 +46,7 @@ export type GetOrganizationOutput = { createdAt: Date; updatedAt: Date; }; - role: string; + role: string | UserRole; }; export type OrganizationMember = { @@ -56,7 +55,7 @@ export type OrganizationMember = { firstName: string; lastName: string; email: string; - role: string; + role: UserRole; status: string; joinedAt: Date; }; @@ -67,7 +66,7 @@ export type GetOrganizationMembersOutput = { export type InviteMemberInput = { email: string; - role: "OWNER" | "MANAGER" | "MEMBER" | "VIEWER"; + role: UserRole; }; export type AcceptInviteInput = z.infer; @@ -84,14 +83,14 @@ export type InviteMemberOutput = { export type GetUserOrganizationOutput = { organization: IOrganization; - role: string; + role: string | UserRole; }; export type PendingMembershipData = { orgId: string; userId?: string; email?: string; - role: "OWNER" | "MANAGER" | "MEMBER" | "VIEWER"; + role: UserRole; status: "PENDING"; inviteTokenHash: string; inviteExpiresAt: Date; diff --git a/src/modules/organization/routes/organization.v1.routes.ts b/src/modules/organization/routes/organization.v1.routes.ts index 7e27e19..e86039b 100644 --- a/src/modules/organization/routes/organization.v1.routes.ts +++ b/src/modules/organization/routes/organization.v1.routes.ts @@ -2,6 +2,8 @@ import { Router } from "express"; import validateResource from "@middlewares/validators"; import authenticate from "@middlewares/authenticate"; import requireRole from "@middlewares/requireRole"; +import { USER_ROLES } from "@constants"; + import { createOrganizationSchema, inviteMemberSchema, @@ -29,14 +31,14 @@ organizationRouter.get("/", authenticate, getOrganization); organizationRouter.get( "/members", authenticate, - requireRole(["OWNER", "MANAGER"]), + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), getOrganizationMembers, ); organizationRouter.post( "/invite", authenticate, - requireRole(["OWNER", "MANAGER"]), + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), validateResource(inviteMemberSchema), inviteMember, ); diff --git a/src/modules/project/__tests__/integration/project.v1.test.ts b/src/modules/project/__tests__/integration/project.v1.test.ts new file mode 100644 index 0000000..a1890aa --- /dev/null +++ b/src/modules/project/__tests__/integration/project.v1.test.ts @@ -0,0 +1,108 @@ +import request from "supertest"; +import app from "@app"; +import { clearDB } from "@tests/utils"; +import { seedOneUserWithOrg } from "@tests/helpers/seed"; +import { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "../../../auth/__tests__/helpers/testHelpers"; +import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; + +const { verifiedUserEmail } = TEST_CONSTANTS; + +describe("Project Module Integration Tests", () => { + let accessToken: string; + let orgId: string; + let clientId: string; + + beforeEach(async () => { + await clearDB(); + const { user, organization } = await seedOneUserWithOrg({ + email: verifiedUserEmail, + isEmailVerified: true, + }); + orgId = organization._id.toString(); + accessToken = createSignedAccessTokenCookie( + generateAccessToken({ id: user._id.toString(), email: user.email }), + ); + + // Seed a client for project testing + const ClientModel = (await import("../../../client/client.model")).default; + const client = await new ClientModel({ name: "Test Client", orgId }).save(); + clientId = client.id.toString(); + }); + + describe("POST /api/v1/projects", () => { + it("should create a new project", async () => { + const res = await request(app) + .post("/api/v1/projects") + .set("Cookie", [accessToken]) + .send({ + name: "Website Redesign", + clientId: clientId, + isBillable: true, + orgId: orgId, + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.name).toBe("Website Redesign"); + expect(res.body.data.clientId).toBe(clientId); + }); + }); + + describe("GET /api/v1/projects", () => { + it("should get all projects for organization", async () => { + const ProjectModel = (await import("@modules/project/project.model")) + .default; + await new ProjectModel({ name: "Project A", orgId, clientId }).save(); + + const res = await request(app) + .get("/api/v1/projects") + .set("Cookie", [accessToken]); + + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(1); + }); + }); + + describe("PATCH /api/v1/projects/:id", () => { + it("should update project info", async () => { + const ProjectModel = (await import("@modules/project/project.model")) + .default; + const project = await new ProjectModel({ + name: "Old Project", + orgId, + clientId, + }).save(); + + const res = await request(app) + .patch(`/api/v1/projects/${project._id}`) + .set("Cookie", [accessToken]) + .send({ name: "New Project" }); + + expect(res.status).toBe(200); + expect(res.body.data.name).toBe("New Project"); + }); + }); + + describe("DELETE /api/v1/projects/:id", () => { + it("should delete a project", async () => { + const ProjectModel = (await import("@modules/project/project.model")) + .default; + const project = await new ProjectModel({ + name: "To Delete", + orgId, + clientId, + }).save(); + + const res = await request(app) + .delete(`/api/v1/projects/${project._id}`) + .set("Cookie", [accessToken]); + + expect(res.status).toBe(200); + const deletedProject = await ProjectModel.findById(project._id); + expect(deletedProject).toBeNull(); + }); + }); +}); diff --git a/src/modules/project/project.controller.ts b/src/modules/project/project.controller.ts new file mode 100644 index 0000000..690c4cc --- /dev/null +++ b/src/modules/project/project.controller.ts @@ -0,0 +1,105 @@ +import { Request, Response } from "express"; +import ProjectService from "./project.service"; +import AppError from "@utils/AppError"; +import { IErrorPayload } from "src/types"; +import { routeTryCatcher } from "@utils/routeTryCatcher"; + +export const createProject = routeTryCatcher( + async (req: Request, res: Response) => { + const { name, clientId, color, isBillable, orgId } = req.body; + const userOrg = req.userOrg!; + + if (userOrg._id.toString() !== orgId) { + throw AppError.forbidden( + "You can only create projects for your own organization", + ); + } + + const result = await ProjectService.createProject({ + name, + clientId, + color, + isBillable, + orgId, + }); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(201).json(result); + }, +); + +export const getProjects = routeTryCatcher( + async (req: Request, res: Response) => { + const userOrg = req.userOrg!; + const projects = await ProjectService.getProjectsByOrgId( + userOrg._id.toString(), + ); + + res.status(200).json({ + success: true, + data: projects, + }); + }, +); + +export const updateProject = routeTryCatcher( + async (req: Request, res: Response) => { + const { id } = req.params; + const userOrg = req.userOrg!; + + if (!id) throw AppError.badRequest("Project ID is required"); + + const project = await ProjectService.getProjectById(id); + if (!project) throw AppError.notFound("Project not found"); + + if (userOrg._id.toString() !== project.orgId.toString()) { + throw AppError.forbidden( + "You do not have permission to update this project", + ); + } + + const result = await ProjectService.updateProject(id, req.body); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(200).json(result); + }, +); + +export const deleteProject = routeTryCatcher( + async (req: Request, res: Response) => { + const { id } = req.params; + const userOrg = req.userOrg!; + + if (!id) throw AppError.badRequest("Project ID is required"); + + const project = await ProjectService.getProjectById(id); + if (!project) throw AppError.notFound("Project not found"); + + if (userOrg._id.toString() !== project.orgId.toString()) { + throw AppError.forbidden( + "You do not have permission to delete this project", + ); + } + + const result = await ProjectService.deleteProject(id); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(200).json(result); + }, +); + +export default { + createProject, + getProjects, + updateProject, + deleteProject, +}; diff --git a/src/modules/project/project.docs.ts b/src/modules/project/project.docs.ts new file mode 100644 index 0000000..378a324 --- /dev/null +++ b/src/modules/project/project.docs.ts @@ -0,0 +1,59 @@ +import { Tspec } from "tspec"; +import { + CreateProjectInput, + UpdateProjectInput, + ProjectOutput, +} from "./project.types"; +import { ISuccessPayload, IErrorPayload } from "src/types"; + +export type ProjectApiSpec = Tspec.DefineApiSpec<{ + basePath: "/api/v1/projects"; + tags: ["Projects"]; + paths: { + "/": { + post: { + summary: "Create a new project"; + body: CreateProjectInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + }; + }; + get: { + summary: "Get all projects for the organization"; + responses: { + 200: ISuccessPayload; + 401: IErrorPayload; + 403: IErrorPayload; + }; + }; + }; + "/{id}": { + patch: { + summary: "Update a project"; + path: { id: string }; + body: UpdateProjectInput; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + delete: { + summary: "Delete a project"; + path: { id: string }; + responses: { + 200: ISuccessPayload<{ message: string }>; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + }; + }; +}>; diff --git a/src/modules/project/project.model.ts b/src/modules/project/project.model.ts new file mode 100644 index 0000000..67a8d22 --- /dev/null +++ b/src/modules/project/project.model.ts @@ -0,0 +1,46 @@ +import mongoose, { Schema } from "mongoose"; +import { IProject } from "./project.types"; +import { GLOBAL_STATUS } from "@constants"; + +const projectSchema: Schema = new Schema( + { + name: { + type: String, + required: true, + trim: true, + }, + clientId: { + type: Schema.Types.ObjectId, + ref: "Client", + required: true, + }, + orgId: { + type: Schema.Types.ObjectId, + ref: "Organization", + required: true, + }, + color: { + type: String, + default: "#808080", + }, + isBillable: { + type: Boolean, + default: true, + }, + status: { + type: String, + enum: Object.values(GLOBAL_STATUS), + default: GLOBAL_STATUS.ACTIVE, + }, + }, + { + timestamps: true, + }, +); + +// Ensure name is unique within the same organization +projectSchema.index({ name: 1, orgId: 1 }, { unique: true }); + +const ProjectModel = mongoose.model("Project", projectSchema); + +export default ProjectModel; diff --git a/src/modules/project/project.service.ts b/src/modules/project/project.service.ts new file mode 100644 index 0000000..8d40f67 --- /dev/null +++ b/src/modules/project/project.service.ts @@ -0,0 +1,133 @@ +import { + CreateProjectInput, + UpdateProjectInput, + ProjectOutput, + ProjectBase, +} from "./project.types"; +import ProjectModel from "./project.model"; +import { ISuccessPayload, IErrorPayload, Mappable } from "src/types"; + +const mapToOutput = (doc: ProjectBase & Mappable): ProjectOutput => { + const clientId = doc.clientId as unknown as + | { _id?: { toString(): string } } + | { toString(): string }; + return { + id: doc._id.toString(), + name: doc.name, + clientId: + ("_id" in clientId ? clientId._id?.toString() : clientId.toString()) || + "", + orgId: doc.orgId.toString(), + color: doc.color, + isBillable: doc.isBillable, + status: doc.status, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; +}; + +const ProjectService = { + createProject: async ( + input: CreateProjectInput, + ): Promise | IErrorPayload> => { + try { + const existingProject = await ProjectModel.findOne({ + name: input.name, + orgId: input.orgId, + }); + + if (existingProject) { + return { + success: false, + error: "Project with this name already exists in the organization", + }; + } + + const project = new ProjectModel(input); + await project.save(); + + return { + success: true, + data: mapToOutput(project as unknown as ProjectBase & Mappable), + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + getProjectsByOrgId: async (orgId: string): Promise => { + const projects = await ProjectModel.find({ + orgId, + status: "ACTIVE", + }).populate("clientId"); + return projects.map((doc) => + mapToOutput(doc as unknown as ProjectBase & Mappable), + ); + }, + + getProjectById: async (id: string): Promise => { + const project = await ProjectModel.findById(id).populate("clientId"); + return project + ? mapToOutput(project as unknown as ProjectBase & Mappable) + : null; + }, + + updateProject: async ( + id: string, + input: UpdateProjectInput, + ): Promise | IErrorPayload> => { + try { + const project = await ProjectModel.findById(id); + if (!project) { + return { + success: false, + error: "Project not found", + }; + } + + Object.assign(project, input); + await project.save(); + + return { + success: true, + data: mapToOutput(project as unknown as ProjectBase & Mappable), + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + deleteProject: async ( + id: string, + ): Promise | IErrorPayload> => { + try { + const project = await ProjectModel.findById(id); + if (!project) { + return { + success: false, + error: "Project not found", + }; + } + + await ProjectModel.findByIdAndDelete(id); + + return { + success: true, + data: { message: "Project deleted successfully" }, + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, +}; + +export default ProjectService; diff --git a/src/modules/project/project.types.ts b/src/modules/project/project.types.ts new file mode 100644 index 0000000..7b8b905 --- /dev/null +++ b/src/modules/project/project.types.ts @@ -0,0 +1,44 @@ +import mongoose from "mongoose"; +import { GlobalStatus } from "@constants"; + +export interface ProjectBase { + name: string; + clientId: mongoose.Types.ObjectId; + orgId: mongoose.Types.ObjectId; + color: string; + isBillable: boolean; + status: GlobalStatus; +} + +export interface IProject extends ProjectBase, mongoose.Document { + createdAt: Date; + updatedAt: Date; +} + +export interface ProjectOutput { + id: string; + name: string; + clientId: string; + orgId: string; + color: string; + isBillable: boolean; + status: GlobalStatus; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateProjectInput { + name: string; + clientId: string; + orgId: string; + color?: string; + isBillable?: boolean; +} + +export interface UpdateProjectInput { + name?: string; + clientId?: string; + color?: string; + isBillable?: boolean; + status?: GlobalStatus; +} diff --git a/src/modules/project/project.validators.ts b/src/modules/project/project.validators.ts new file mode 100644 index 0000000..054d4d3 --- /dev/null +++ b/src/modules/project/project.validators.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { GLOBAL_STATUS } from "@constants"; +import { zObjectId } from "@utils/validators"; + +export const createProjectSchema = z.object({ + name: z.string().min(1).max(100), + clientId: zObjectId, + orgId: zObjectId, + color: z + .string() + .regex(/^#[0-9A-F]{6}$/i) + .optional(), + isBillable: z.boolean().optional(), +}); + +export const updateProjectSchema = z.object({ + name: z.string().min(1).max(100).optional(), + clientId: zObjectId.optional(), + color: z + .string() + .regex(/^#[0-9A-F]{6}$/i) + .optional(), + isBillable: z.boolean().optional(), + status: z + .enum(Object.values(GLOBAL_STATUS) as [string, ...string[]]) + .optional(), +}); diff --git a/src/modules/project/routes/project.v1.routes.ts b/src/modules/project/routes/project.v1.routes.ts new file mode 100644 index 0000000..5f12532 --- /dev/null +++ b/src/modules/project/routes/project.v1.routes.ts @@ -0,0 +1,44 @@ +import { Router } from "express"; +import ProjectController from "../project.controller"; +import authenticate from "@middlewares/authenticate"; +import validate from "@middlewares/validators"; +import { + createProjectSchema, + updateProjectSchema, +} from "../project.validators"; +import requireRole from "@middlewares/requireRole"; +import { USER_ROLES } from "@constants"; + +const projectRouter = Router(); + +projectRouter.use(authenticate); + +projectRouter.post( + "/", + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + validate(createProjectSchema), + ProjectController.createProject, +); +projectRouter.get( + "/", + requireRole([ + USER_ROLES.OWNER, + USER_ROLES.MANAGER, + USER_ROLES.MEMBER, + USER_ROLES.VIEWER, + ]), + ProjectController.getProjects, +); +projectRouter.patch( + "/:id", + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + validate(updateProjectSchema), + ProjectController.updateProject, +); +projectRouter.delete( + "/:id", + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + ProjectController.deleteProject, +); + +export default projectRouter; diff --git a/src/modules/tag/__tests__/integration/tag.v1.test.ts b/src/modules/tag/__tests__/integration/tag.v1.test.ts new file mode 100644 index 0000000..f23ae01 --- /dev/null +++ b/src/modules/tag/__tests__/integration/tag.v1.test.ts @@ -0,0 +1,233 @@ +import request from "supertest"; +import app from "@app"; +import { clearDB } from "@tests/utils"; +import { seedOneUserWithOrg, seedUserInOrg } from "@tests/helpers/seed"; +import { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "../../../auth/__tests__/helpers/testHelpers"; +import OrganizationService from "@modules/organization/organization.service"; +import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; +import { ISuccessPayload } from "src/types"; +import { GetOrganizationOutput } from "@modules/organization/organization.types"; + +const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; + +describe("POST /api/v1/tags", () => { + let accessToken: string; + let orgId: string; + + beforeEach(async () => { + await clearDB(); + const { user } = await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + const orgResult = await OrganizationService.getUserOrganization( + user._id.toString(), + ); + if (!orgResult.success) throw new Error("Org not found"); + orgId = ( + orgResult as ISuccessPayload + ).data.organization.id.toString(); + + const token = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + accessToken = createSignedAccessTokenCookie(token); + }); + + it("should create a new tag", async () => { + const res = await request(app) + .post("/api/v1/tags") + .set("Cookie", [accessToken]) + .send({ + name: "Billable", + color: "#FF0000", + orgId: orgId, + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.name).toBe("Billable"); + expect(res.body.data.orgId).toBe(orgId); + }); + + it("should return 401 if not authenticated", async () => { + const res = await request(app).post("/api/v1/tags").send({ + name: "Billable", + orgId: orgId, + }); + + expect(res.status).toBe(401); + }); + + it("should return 403 if user is a MEMBER (only MANAGER/OWNER allowed)", async () => { + const { user } = await seedUserInOrg(orgId, {}, "MEMBER"); + const memberToken = createSignedAccessTokenCookie( + generateAccessToken({ id: user._id.toString(), email: user.email }), + ); + + const res = await request(app) + .post("/api/v1/tags") + .set("Cookie", [memberToken]) + .send({ + name: "Member Tag", + color: "#0000FF", + orgId: orgId, + }); + + expect(res.status).toBe(403); + }); +}); + +describe("GET /api/v1/tags", () => { + let accessToken: string; + let orgId: string; + + beforeEach(async () => { + await clearDB(); + const { user, organization } = await seedOneUserWithOrg({ + email: verifiedUserEmail, + isEmailVerified: true, + }); + orgId = organization._id.toString(); + accessToken = createSignedAccessTokenCookie( + generateAccessToken({ id: user._id.toString(), email: user.email }), + ); + + // Create a tag directly via model for testing GET + const TagModel = (await import("../../tag.model")).default; + await new TagModel({ name: "Tag 1", orgId }).save(); + await new TagModel({ name: "Tag 2", orgId }).save(); + }); + + it("should get all tags for the organization", async () => { + const res = await request(app) + .get("/api/v1/tags") + .set("Cookie", [accessToken]); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.length).toBe(2); + }); + + it("should allow a VIEWER to get all tags", async () => { + const { user } = await seedUserInOrg(orgId, {}, "VIEWER"); + const viewerToken = createSignedAccessTokenCookie( + generateAccessToken({ id: user._id.toString(), email: user.email }), + ); + + const res = await request(app) + .get("/api/v1/tags") + .set("Cookie", [viewerToken]); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); +}); + +describe("PATCH /api/v1/tags/:id", () => { + let accessToken: string; + let tagId: string; + let orgId: string; + + beforeEach(async () => { + await clearDB(); + const { user, organization } = await seedOneUserWithOrg({ + email: verifiedUserEmail, + isEmailVerified: true, + }); + orgId = organization._id.toString(); + accessToken = createSignedAccessTokenCookie( + generateAccessToken({ id: user._id.toString(), email: user.email }), + ); + + const TagModel = (await import("../../tag.model")).default; + const tag = await new TagModel({ + name: "Old Name", + orgId: organization._id, + }).save(); + tagId = tag.id.toString(); + }); + + it("should update a tag name", async () => { + const res = await request(app) + .patch(`/api/v1/tags/${tagId}`) + .set("Cookie", [accessToken]) + .send({ name: "New Name" }); + + expect(res.status).toBe(200); + expect(res.body.data.name).toBe("New Name"); + }); + + it("should return 403 if a VIEWER tries to update a tag", async () => { + // Seed a viewer in the SAME organization to specifically test role permissions (requireRole middleware) + const { user } = await seedUserInOrg(orgId, {}, "VIEWER"); + const viewerToken = createSignedAccessTokenCookie( + generateAccessToken({ id: user._id.toString(), email: user.email }), + ); + + const res = await request(app) + .patch(`/api/v1/tags/${tagId}`) + .set("Cookie", [viewerToken]) + .send({ name: "Viewer Edit" }); + + expect(res.status).toBe(403); + }); +}); + +describe("DELETE /api/v1/tags/:id", () => { + let accessToken: string; + let tagId: string; + let orgId: string; + + beforeEach(async () => { + await clearDB(); + const { user, organization } = await seedOneUserWithOrg({ + email: verifiedUserEmail, + isEmailVerified: true, + }); + orgId = organization._id.toString(); + accessToken = createSignedAccessTokenCookie( + generateAccessToken({ id: user._id.toString(), email: user.email }), + ); + + const TagModel = (await import("../../tag.model")).default; + const tag = await new TagModel({ + name: "To Delete", + orgId: organization._id, + }).save(); + tagId = tag.id.toString(); + }); + + it("should delete a tag", async () => { + const res = await request(app) + .delete(`/api/v1/tags/${tagId}`) + .set("Cookie", [accessToken]); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const TagModel = (await import("../../tag.model")).default; + const deletedTag = await TagModel.findById(tagId); + expect(deletedTag).toBeNull(); + }); + + it("should return 403 if a MEMBER tries to delete a tag", async () => { + // Seed a member in the SAME organization to specifically test role permissions (requireRole middleware) + const { user } = await seedUserInOrg(orgId, {}, "MEMBER"); + const memberToken = createSignedAccessTokenCookie( + generateAccessToken({ id: user._id.toString(), email: user.email }), + ); + + const res = await request(app) + .delete(`/api/v1/tags/${tagId}`) + .set("Cookie", [memberToken]); + + expect(res.status).toBe(403); + }); +}); diff --git a/src/modules/tag/routes/tag.v1.routes.ts b/src/modules/tag/routes/tag.v1.routes.ts new file mode 100644 index 0000000..261e04a --- /dev/null +++ b/src/modules/tag/routes/tag.v1.routes.ts @@ -0,0 +1,41 @@ +import { Router } from "express"; +import TagController from "../tag.controller"; +import authenticate from "@middlewares/authenticate"; +import validate from "@middlewares/validators"; +import { createTagSchema, updateTagSchema } from "../tag.validators"; +import requireRole from "@middlewares/requireRole"; +import { USER_ROLES } from "@constants"; + +const tagRouter = Router(); + +tagRouter.use(authenticate); + +tagRouter.post( + "/", + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + validate(createTagSchema), + TagController.createTag, +); +tagRouter.get( + "/", + requireRole([ + USER_ROLES.OWNER, + USER_ROLES.MANAGER, + USER_ROLES.MEMBER, + USER_ROLES.VIEWER, + ]), + TagController.getTags, +); +tagRouter.patch( + "/:id", + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + validate(updateTagSchema), + TagController.updateTag, +); +tagRouter.delete( + "/:id", + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + TagController.deleteTag, +); + +export default tagRouter; diff --git a/src/modules/tag/tag.controller.ts b/src/modules/tag/tag.controller.ts new file mode 100644 index 0000000..2a1b987 --- /dev/null +++ b/src/modules/tag/tag.controller.ts @@ -0,0 +1,91 @@ +import { Request, Response } from "express"; +import TagService from "./tag.service"; +import AppError from "@utils/AppError"; +import { IErrorPayload } from "src/types"; +import { routeTryCatcher } from "@utils/routeTryCatcher"; + +export const createTag = routeTryCatcher( + async (req: Request, res: Response) => { + const { name, color, orgId } = req.body; + const userOrg = req.userOrg!; + + if (userOrg._id.toString() !== orgId) { + throw AppError.forbidden( + "You can only create tags for your own organization", + ); + } + + const result = await TagService.createTag({ name, color, orgId }); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(201).json(result); + }, +); + +export const getTags = routeTryCatcher(async (req: Request, res: Response) => { + const userOrg = req.userOrg!; + const tags = await TagService.getTagsByOrgId(userOrg._id.toString()); + + res.status(200).json({ + success: true, + data: tags, + }); +}); + +export const updateTag = routeTryCatcher( + async (req: Request, res: Response) => { + const { id } = req.params; + const userOrg = req.userOrg!; + + if (!id) throw AppError.badRequest("Tag ID is required"); + + const tag = await TagService.getTagById(id); + if (!tag) throw AppError.notFound("Tag not found"); + + if (userOrg._id.toString() !== tag.orgId.toString()) { + throw AppError.forbidden("You do not have permission to update this tag"); + } + + const result = await TagService.updateTag(id, req.body); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(200).json(result); + }, +); + +export const deleteTag = routeTryCatcher( + async (req: Request, res: Response) => { + const { id } = req.params; + const userOrg = req.userOrg!; + + if (!id) throw AppError.badRequest("Tag ID is required"); + + const tag = await TagService.getTagById(id); + if (!tag) throw AppError.notFound("Tag not found"); + + if (userOrg._id.toString() !== tag.orgId.toString()) { + throw AppError.forbidden("You do not have permission to delete this tag"); + } + + const result = await TagService.deleteTag(id); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(200).json(result); + }, +); + +export default { + createTag, + getTags, + updateTag, + deleteTag, +}; diff --git a/src/modules/tag/tag.docs.ts b/src/modules/tag/tag.docs.ts new file mode 100644 index 0000000..287ab2a --- /dev/null +++ b/src/modules/tag/tag.docs.ts @@ -0,0 +1,55 @@ +import { Tspec } from "tspec"; +import { CreateTagInput, UpdateTagInput, TagOutput } from "./tag.types"; +import { ISuccessPayload, IErrorPayload } from "src/types"; + +export type TagApiSpec = Tspec.DefineApiSpec<{ + basePath: "/api/v1/tags"; + tags: ["Tags"]; + paths: { + "/": { + post: { + summary: "Create a new tag"; + body: CreateTagInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + }; + }; + get: { + summary: "Get all tags for the organization"; + responses: { + 200: ISuccessPayload; + 401: IErrorPayload; + 403: IErrorPayload; + }; + }; + }; + "/{id}": { + patch: { + summary: "Update a tag"; + path: { id: string }; + body: UpdateTagInput; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + delete: { + summary: "Delete a tag"; + path: { id: string }; + responses: { + 200: ISuccessPayload<{ message: string }>; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + }; + }; +}>; diff --git a/src/modules/tag/tag.model.ts b/src/modules/tag/tag.model.ts new file mode 100644 index 0000000..f0be5c6 --- /dev/null +++ b/src/modules/tag/tag.model.ts @@ -0,0 +1,37 @@ +import mongoose, { Schema } from "mongoose"; +import { ITag } from "./tag.types"; +import { GLOBAL_STATUS } from "@constants"; + +const tagSchema: Schema = new Schema( + { + name: { + type: String, + required: true, + trim: true, + }, + color: { + type: String, + default: "#808080", // Default gray + }, + orgId: { + type: Schema.Types.ObjectId, + ref: "Organization", + required: true, + }, + status: { + type: String, + enum: Object.values(GLOBAL_STATUS), + default: GLOBAL_STATUS.ACTIVE, + }, + }, + { + timestamps: true, + }, +); + +// Ensure name is unique within the same organization +tagSchema.index({ name: 1, orgId: 1 }, { unique: true }); + +const TagModel = mongoose.model("Tag", tagSchema); + +export default TagModel; diff --git a/src/modules/tag/tag.service.ts b/src/modules/tag/tag.service.ts new file mode 100644 index 0000000..e4fe825 --- /dev/null +++ b/src/modules/tag/tag.service.ts @@ -0,0 +1,127 @@ +import { + CreateTagInput, + UpdateTagInput, + TagOutput, + TagBase, +} from "./tag.types"; +import TagModel from "./tag.model"; +import { ISuccessPayload, IErrorPayload, Mappable } from "src/types"; + +const mapToOutput = (doc: TagBase & Mappable): TagOutput => { + return { + id: doc._id.toString(), + name: doc.name, + color: doc.color ?? undefined, + orgId: doc.orgId.toString(), + status: doc.status, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; +}; + +const TagService = { + createTag: async ( + input: CreateTagInput, + ): Promise | IErrorPayload> => { + try { + const existingTag = await TagModel.findOne({ + name: input.name, + orgId: input.orgId, + }); + + if (existingTag) { + return { + success: false, + error: "Tag with this name already exists in the organization", + }; + } + + const tag = new TagModel({ + name: input.name, + color: input.color, + orgId: input.orgId, + }); + + await tag.save(); + + return { + success: true, + data: mapToOutput(tag as unknown as TagBase & Mappable), + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + getTagsByOrgId: async (orgId: string): Promise => { + const tags = await TagModel.find({ orgId, status: "ACTIVE" }); + return tags.map((doc) => mapToOutput(doc as unknown as TagBase & Mappable)); + }, + + getTagById: async (id: string): Promise => { + const tag = await TagModel.findById(id); + return tag ? mapToOutput(tag as unknown as TagBase & Mappable) : null; + }, + + updateTag: async ( + id: string, + input: UpdateTagInput, + ): Promise | IErrorPayload> => { + try { + const tag = await TagModel.findById(id); + if (!tag) { + return { + success: false, + error: "Tag not found", + }; + } + + if (input.name) tag.name = input.name; + if (input.color) tag.color = input.color; + if (input.status) tag.status = input.status; + + await tag.save(); + + return { + success: true, + data: mapToOutput(tag as unknown as TagBase & Mappable), + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + deleteTag: async ( + id: string, + ): Promise | IErrorPayload> => { + try { + const tag = await TagModel.findById(id); + if (!tag) { + return { + success: false, + error: "Tag not found", + }; + } + + await TagModel.findByIdAndDelete(id); + + return { + success: true, + data: { message: "Tag deleted successfully" }, + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, +}; + +export default TagService; diff --git a/src/modules/tag/tag.types.ts b/src/modules/tag/tag.types.ts new file mode 100644 index 0000000..e7450e5 --- /dev/null +++ b/src/modules/tag/tag.types.ts @@ -0,0 +1,36 @@ +import mongoose from "mongoose"; +import { GlobalStatus } from "@constants"; + +export interface TagBase { + name: string; + color?: string | undefined; + orgId: mongoose.Types.ObjectId; + status: GlobalStatus; +} + +export interface ITag extends TagBase, mongoose.Document { + createdAt: Date; + updatedAt: Date; +} + +export interface TagOutput { + id: string; + name: string; + color?: string | undefined; + orgId: string; + status: GlobalStatus; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateTagInput { + name: string; + color?: string; + orgId: string; +} + +export interface UpdateTagInput { + name?: string; + color?: string; + status?: GlobalStatus; +} diff --git a/src/modules/tag/tag.validators.ts b/src/modules/tag/tag.validators.ts new file mode 100644 index 0000000..8598285 --- /dev/null +++ b/src/modules/tag/tag.validators.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import { GLOBAL_STATUS } from "@constants"; +import { zObjectId } from "@utils/validators"; + +export const createTagSchema = z.object({ + name: z.string().min(1).max(50), + color: z + .string() + .regex(/^#[0-9A-F]{6}$/i) + .optional(), + orgId: zObjectId, +}); + +export const updateTagSchema = z.object({ + name: z.string().min(1).max(50).optional(), + color: z + .string() + .regex(/^#[0-9A-F]{6}$/i) + .optional(), + status: z + .enum(Object.values(GLOBAL_STATUS) as [string, ...string[]]) + .optional(), +}); diff --git a/src/modules/task/__tests__/integration/task.v1.test.ts b/src/modules/task/__tests__/integration/task.v1.test.ts new file mode 100644 index 0000000..1d303c9 --- /dev/null +++ b/src/modules/task/__tests__/integration/task.v1.test.ts @@ -0,0 +1,130 @@ +import request from "supertest"; +import app from "@app"; +import { clearDB } from "@tests/utils"; +import { seedOneUserWithOrg } from "@tests/helpers/seed"; +import { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "../../../auth/__tests__/helpers/testHelpers"; +import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; + +const { verifiedUserEmail } = TEST_CONSTANTS; + +describe("Task Module Integration Tests", () => { + let accessToken: string; + let orgId: string; + let projectId: string; + + beforeEach(async () => { + await clearDB(); + const { user, organization } = await seedOneUserWithOrg({ + email: verifiedUserEmail, + isEmailVerified: true, + }); + orgId = organization._id.toString(); + accessToken = createSignedAccessTokenCookie( + generateAccessToken({ id: user._id.toString(), email: user.email }), + ); + + // Seed Client and Project for task testing + const ClientModel = (await import("../../../client/client.model")).default; + const ProjectModel = (await import("../../../project/project.model")) + .default; + + const client = await new ClientModel({ name: "Test Client", orgId }).save(); + const project = await new ProjectModel({ + name: "Web App", + orgId, + clientId: client._id, + }).save(); + projectId = project.id.toString(); + }); + + describe("POST /api/v1/tasks", () => { + it("should create a new task", async () => { + const res = await request(app) + .post("/api/v1/tasks") + .set("Cookie", [accessToken]) + .send({ + name: "Design Layout", + projectId: projectId, + orgId: orgId, + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.name).toBe("Design Layout"); + expect(res.body.data.projectId).toBe(projectId); + }); + }); + + describe("GET /api/v1/tasks", () => { + it("should get all tasks for organization", async () => { + const TaskModel = (await import("@modules/task/task.model")).default; + await new TaskModel({ name: "Task 1", projectId, orgId }).save(); + + const res = await request(app) + .get("/api/v1/tasks") + .set("Cookie", [accessToken]); + + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(1); + }); + }); + + describe("PATCH /api/v1/tasks/:id", () => { + it("should update task info", async () => { + const TaskModel = (await import("@modules/task/task.model")).default; + const task = await new TaskModel({ + name: "Old Task", + projectId, + orgId, + }).save(); + + const res = await request(app) + .patch(`/api/v1/tasks/${task._id}`) + .set("Cookie", [accessToken]) + .send({ name: "New Task", status: "DONE" }); + + expect(res.status).toBe(200); + expect(res.body.data.name).toBe("New Task"); + expect(res.body.data.status).toBe("DONE"); + }); + + it("should update task status to IN_PROGRESS", async () => { + const TaskModel = (await import("@modules/task/task.model")).default; + const task = await new TaskModel({ + name: "Work Task", + projectId, + orgId, + }).save(); + + const res = await request(app) + .patch(`/api/v1/tasks/${task._id}`) + .set("Cookie", [accessToken]) + .send({ status: "IN_PROGRESS" }); + + expect(res.status).toBe(200); + expect(res.body.data.status).toBe("IN_PROGRESS"); + }); + }); + + describe("DELETE /api/v1/tasks/:id", () => { + it("should delete a task", async () => { + const TaskModel = (await import("@modules/task/task.model")).default; + const task = await new TaskModel({ + name: "To Delete", + projectId, + orgId, + }).save(); + + const res = await request(app) + .delete(`/api/v1/tasks/${task._id}`) + .set("Cookie", [accessToken]); + + expect(res.status).toBe(200); + const deletedTask = await TaskModel.findById(task._id); + expect(deletedTask).toBeNull(); + }); + }); +}); diff --git a/src/modules/task/routes/task.v1.routes.ts b/src/modules/task/routes/task.v1.routes.ts new file mode 100644 index 0000000..7bec3a5 --- /dev/null +++ b/src/modules/task/routes/task.v1.routes.ts @@ -0,0 +1,41 @@ +import { Router } from "express"; +import TaskController from "../task.controller"; +import authenticate from "@middlewares/authenticate"; +import validate from "@middlewares/validators"; +import { createTaskSchema, updateTaskSchema } from "../task.validators"; +import requireRole from "@middlewares/requireRole"; +import { USER_ROLES } from "@constants"; + +const taskRouter = Router(); + +taskRouter.use(authenticate); + +taskRouter.post( + "/", + requireRole([USER_ROLES.MANAGER, USER_ROLES.OWNER]), + validate(createTaskSchema), + TaskController.createTask, +); +taskRouter.get( + "/", + requireRole([ + USER_ROLES.MANAGER, + USER_ROLES.OWNER, + USER_ROLES.MEMBER, + USER_ROLES.VIEWER, + ]), + TaskController.getTasks, +); +taskRouter.patch( + "/:id", + requireRole([USER_ROLES.MANAGER, USER_ROLES.OWNER]), + validate(updateTaskSchema), + TaskController.updateTask, +); +taskRouter.delete( + "/:id", + requireRole([USER_ROLES.MANAGER, USER_ROLES.OWNER]), + TaskController.deleteTask, +); + +export default taskRouter; diff --git a/src/modules/task/task.controller.ts b/src/modules/task/task.controller.ts new file mode 100644 index 0000000..e8cc023 --- /dev/null +++ b/src/modules/task/task.controller.ts @@ -0,0 +1,109 @@ +import { Request, Response } from "express"; +import TaskService from "./task.service"; +import ProjectService from "@modules/project/project.service"; +import AppError from "@utils/AppError"; +import { IErrorPayload } from "src/types"; +import { routeTryCatcher } from "@utils/routeTryCatcher"; + +export const createTask = routeTryCatcher( + async (req: Request, res: Response) => { + const { name, projectId, isBillable, orgId } = req.body; + const userOrg = req.userOrg!; + + if (userOrg._id.toString() !== orgId) { + throw AppError.forbidden( + "You can only create tasks for your own organization", + ); + } + + // Verify project belongs to organization + const project = await ProjectService.getProjectById(projectId); + if (!project || project.orgId.toString() !== userOrg._id.toString()) { + throw AppError.badRequest( + "Invalid project or project does not belong to your organization", + ); + } + + const result = await TaskService.createTask({ + name, + projectId, + isBillable, + orgId, + }); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(201).json(result); + }, +); + +export const getTasks = routeTryCatcher(async (req: Request, res: Response) => { + const userOrg = req.userOrg!; + const tasks = await TaskService.getTasksByOrgId(userOrg._id.toString()); + + res.status(200).json({ + success: true, + data: tasks, + }); +}); + +export const updateTask = routeTryCatcher( + async (req: Request, res: Response) => { + const { id } = req.params; + const userOrg = req.userOrg!; + + if (!id) throw AppError.badRequest("Task ID is required"); + + const task = await TaskService.getTaskById(id); + if (!task) throw AppError.notFound("Task not found"); + + if (userOrg._id.toString() !== task.orgId.toString()) { + throw AppError.forbidden( + "You do not have permission to update this task", + ); + } + + const result = await TaskService.updateTask(id, req.body); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(200).json(result); + }, +); + +export const deleteTask = routeTryCatcher( + async (req: Request, res: Response) => { + const { id } = req.params; + const userOrg = req.userOrg!; + + if (!id) throw AppError.badRequest("Task ID is required"); + + const task = await TaskService.getTaskById(id); + if (!task) throw AppError.notFound("Task not found"); + + if (userOrg._id.toString() !== task.orgId.toString()) { + throw AppError.forbidden( + "You do not have permission to delete this task", + ); + } + + const result = await TaskService.deleteTask(id); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(200).json(result); + }, +); + +export default { + createTask, + getTasks, + updateTask, + deleteTask, +}; diff --git a/src/modules/task/task.docs.ts b/src/modules/task/task.docs.ts new file mode 100644 index 0000000..31faf46 --- /dev/null +++ b/src/modules/task/task.docs.ts @@ -0,0 +1,55 @@ +import { Tspec } from "tspec"; +import { CreateTaskInput, UpdateTaskInput, TaskOutput } from "./task.types"; +import { ISuccessPayload, IErrorPayload } from "src/types"; + +export type TaskApiSpec = Tspec.DefineApiSpec<{ + basePath: "/api/v1/tasks"; + tags: ["Tasks"]; + paths: { + "/": { + post: { + summary: "Create a new task"; + body: CreateTaskInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + }; + }; + get: { + summary: "Get all tasks for the organization"; + responses: { + 200: ISuccessPayload; + 401: IErrorPayload; + 403: IErrorPayload; + }; + }; + }; + "/{id}": { + patch: { + summary: "Update a task"; + path: { id: string }; + body: UpdateTaskInput; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + delete: { + summary: "Delete a task"; + path: { id: string }; + responses: { + 200: ISuccessPayload<{ message: string }>; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + }; + }; +}>; diff --git a/src/modules/task/task.model.ts b/src/modules/task/task.model.ts new file mode 100644 index 0000000..7e32607 --- /dev/null +++ b/src/modules/task/task.model.ts @@ -0,0 +1,42 @@ +import mongoose, { Schema } from "mongoose"; +import { ITask } from "./task.types"; +import { TASK_STATUS } from "@constants"; + +const taskSchema: Schema = new Schema( + { + name: { + type: String, + required: true, + trim: true, + }, + projectId: { + type: Schema.Types.ObjectId, + ref: "Project", + required: true, + }, + orgId: { + type: Schema.Types.ObjectId, + ref: "Organization", + required: true, + }, + isBillable: { + type: Boolean, + default: true, + }, + status: { + type: String, + enum: Object.values(TASK_STATUS), + default: TASK_STATUS.TODO, + }, + }, + { + timestamps: true, + }, +); + +// Ensure task name is unique within the same project +taskSchema.index({ name: 1, projectId: 1 }, { unique: true }); + +const TaskModel = mongoose.model("Task", taskSchema); + +export default TaskModel; diff --git a/src/modules/task/task.service.ts b/src/modules/task/task.service.ts new file mode 100644 index 0000000..4f4cf92 --- /dev/null +++ b/src/modules/task/task.service.ts @@ -0,0 +1,130 @@ +import { + CreateTaskInput, + UpdateTaskInput, + TaskOutput, + TaskBase, +} from "./task.types"; +import TaskModel from "./task.model"; +import { ISuccessPayload, IErrorPayload, Mappable } from "src/types"; + +const mapToOutput = (doc: TaskBase & Mappable): TaskOutput => { + const projectId = doc.projectId as unknown as + | { _id?: { toString(): string } } + | { toString(): string }; + return { + id: doc._id.toString(), + name: doc.name, + projectId: + ("_id" in projectId ? projectId._id?.toString() : projectId.toString()) || + "", + orgId: doc.orgId.toString(), + isBillable: doc.isBillable, + status: doc.status, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; +}; + +const TaskService = { + createTask: async ( + input: CreateTaskInput, + ): Promise | IErrorPayload> => { + try { + const existingTask = await TaskModel.findOne({ + name: input.name, + projectId: input.projectId, + }); + + if (existingTask) { + return { + success: false, + error: "Task with this name already exists in the project", + }; + } + + const task = new TaskModel(input); + await task.save(); + + return { + success: true, + data: mapToOutput(task as unknown as TaskBase & Mappable), + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + getTasksByOrgId: async (orgId: string): Promise => { + const tasks = await TaskModel.find({ + orgId, + status: { $ne: "ARCHIVED" }, + }).populate("projectId"); + return tasks.map((doc) => + mapToOutput(doc as unknown as TaskBase & Mappable), + ); + }, + + getTaskById: async (id: string): Promise => { + const task = await TaskModel.findById(id).populate("projectId"); + return task ? mapToOutput(task as unknown as TaskBase & Mappable) : null; + }, + + updateTask: async ( + id: string, + input: UpdateTaskInput, + ): Promise | IErrorPayload> => { + try { + const task = await TaskModel.findById(id); + if (!task) { + return { + success: false, + error: "Task not found", + }; + } + + Object.assign(task, input); + await task.save(); + + return { + success: true, + data: mapToOutput(task as unknown as TaskBase & Mappable), + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + deleteTask: async ( + id: string, + ): Promise | IErrorPayload> => { + try { + const task = await TaskModel.findById(id); + if (!task) { + return { + success: false, + error: "Task not found", + }; + } + + await TaskModel.findByIdAndDelete(id); + + return { + success: true, + data: { message: "Task deleted successfully" }, + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, +}; + +export default TaskService; diff --git a/src/modules/task/task.types.ts b/src/modules/task/task.types.ts new file mode 100644 index 0000000..b822be2 --- /dev/null +++ b/src/modules/task/task.types.ts @@ -0,0 +1,40 @@ +import mongoose from "mongoose"; +import { TaskStatus } from "@constants"; + +export interface TaskBase { + name: string; + projectId: mongoose.Types.ObjectId; + orgId: mongoose.Types.ObjectId; + isBillable: boolean; + status: TaskStatus; +} + +export interface ITask extends TaskBase, mongoose.Document { + createdAt: Date; + updatedAt: Date; +} + +export interface TaskOutput { + id: string; + name: string; + projectId: string; + orgId: string; + isBillable: boolean; + status: TaskStatus; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateTaskInput { + name: string; + projectId: string; + orgId: string; + isBillable?: boolean; +} + +export interface UpdateTaskInput { + name?: string; + projectId?: string; + isBillable?: boolean; + status?: TaskStatus; +} diff --git a/src/modules/task/task.validators.ts b/src/modules/task/task.validators.ts new file mode 100644 index 0000000..7bfe42b --- /dev/null +++ b/src/modules/task/task.validators.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; +import { TASK_STATUS } from "@constants"; +import { zObjectId } from "@utils/validators"; + +export const createTaskSchema = z.object({ + name: z.string().min(1).max(100), + projectId: zObjectId, + orgId: zObjectId, + isBillable: z.boolean().optional(), +}); + +export const updateTaskSchema = z.object({ + name: z.string().min(1).max(100).optional(), + projectId: zObjectId.optional(), + isBillable: z.boolean().optional(), + status: z + .enum(Object.values(TASK_STATUS) as [string, ...string[]]) + .optional(), +}); diff --git a/src/modules/time-entry/__tests__/integration/time-entry.v1.test.ts b/src/modules/time-entry/__tests__/integration/time-entry.v1.test.ts new file mode 100644 index 0000000..6bf9592 --- /dev/null +++ b/src/modules/time-entry/__tests__/integration/time-entry.v1.test.ts @@ -0,0 +1,198 @@ +import request from "supertest"; +import app from "@app"; +import { clearDB } from "@tests/utils"; +import { seedOneUserWithOrg } from "@tests/helpers/seed"; +import { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "../../../auth/__tests__/helpers/testHelpers"; +import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; +import TimeEntryModel from "../../time-entry.model"; +import UserModel from "@modules/user/user.model"; + +const { verifiedUserEmail } = TEST_CONSTANTS; + +jest.setTimeout(30000); + +describe("Time Entry Module Integration Tests", () => { + let accessToken: string; + let userId: string; + let orgId: string; + let projectId: string; + let taskId: string; + + beforeEach(async () => { + await clearDB(); + const { user, organization } = await seedOneUserWithOrg({ + email: verifiedUserEmail, + isEmailVerified: true, + }); + userId = user._id.toString(); + orgId = organization._id.toString(); + accessToken = createSignedAccessTokenCookie( + generateAccessToken({ id: userId, email: user.email }), + ); + + // Seed Client, Project and Task + const ClientModel = (await import("../../../client/client.model")).default; + const ProjectModel = (await import("../../../project/project.model")) + .default; + const TaskModel = (await import("../../../task/task.model")).default; + + const client = await new ClientModel({ name: "Client X", orgId }).save(); + const clientId = client.id.toString(); + + const project = await new ProjectModel({ + name: "Project A", + orgId, + clientId, + }).save(); + projectId = project.id.toString(); + + const task = await new TaskModel({ + name: "Task 1", + projectId, + orgId, + }).save(); + taskId = task.id.toString(); + }); + + describe("POST /api/v1/time-entries/start", () => { + it("should start a new timer", async () => { + const res = await request(app) + .post("/api/v1/time-entries/start") + .set("Cookie", [accessToken]) + .send({ + description: "Coding Phase 3", + projectId, + taskId, + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.description).toBe("Coding Phase 3"); + expect(res.body.data.endTime).toBeNull(); + expect(res.body.data.isManual).toBe(false); + expect(res.body.data.projectId).toBe(projectId); + expect(res.body.data.taskId).toBe(taskId); + + const user = await UserModel.findById(userId); + expect(user?.activeTimerId?.toString()).toBe(res.body.data.id); + }); + + it("should auto-stop the previous timer when starting a new one", async () => { + // Start first timer + const res1 = await request(app) + .post("/api/v1/time-entries/start") + .set("Cookie", [accessToken]) + .send({ description: "First Task", projectId, taskId }); + + const firstId = res1.body.data.id; + + // Start second timer + const res2 = await request(app) + .post("/api/v1/time-entries/start") + .set("Cookie", [accessToken]) + .send({ description: "Second Task", projectId, taskId }); + + expect(res2.status).toBe(201); + + const stoppedFirstEntry = await TimeEntryModel.findById(firstId); + expect(stoppedFirstEntry?.endTime).not.toBeNull(); + + const user = await UserModel.findById(userId); + expect(user?.activeTimerId?.toString()).toBe(res2.body.data.id as string); + }); + }); + + describe("POST /api/v1/time-entries/stop", () => { + it("should stop the active timer", async () => { + await request(app) + .post("/api/v1/time-entries/start") + .set("Cookie", [accessToken]) + .send({ description: "To be stopped", projectId, taskId }); + + const res = await request(app) + .post("/api/v1/time-entries/stop") + .set("Cookie", [accessToken]); + + expect(res.status).toBe(200); + expect(res.body.data.endTime).not.toBeNull(); + expect(res.body.data.duration).toBeGreaterThanOrEqual(0); + + const user = await UserModel.findById(userId); + expect(user?.activeTimerId).toBeNull(); + }); + + it("should return 404 if no active timer exists", async () => { + const res = await request(app) + .post("/api/v1/time-entries/stop") + .set("Cookie", [accessToken]); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + }); + }); + + describe("POST /api/v1/time-entries/manual", () => { + it("should create a manual entry", async () => { + const startTime = new Date(Date.now() - 3600000).toISOString(); + const endTime = new Date().toISOString(); + + const res = await request(app) + .post("/api/v1/time-entries/manual") + .set("Cookie", [accessToken]) + .send({ + description: "Manual Entry", + startTime, + endTime, + projectId, + taskId, + }); + + expect(res.status).toBe(201); + expect(res.body.data.duration).toBeGreaterThan(0); + expect(res.body.data.description).toBe("Manual Entry"); + expect(res.body.data.isManual).toBe(true); + expect(res.body.data.projectId).toBe(projectId); + }); + }); + + describe("GET /api/v1/time-entries/active", () => { + it("should get the active timer", async () => { + await request(app) + .post("/api/v1/time-entries/start") + .set("Cookie", [accessToken]) + .send({ description: "Active", projectId, taskId }); + + const res = await request(app) + .get("/api/v1/time-entries/active") + .set("Cookie", [accessToken]); + + expect(res.status).toBe(200); + expect(res.body.data).not.toBeNull(); + expect(res.body.data.description).toBe("Active"); + }); + }); + + describe("GET /api/v1/time-entries", () => { + it("should list time entries", async () => { + await new TimeEntryModel({ + userId, + orgId, + projectId, + taskId, + startTime: new Date(), + description: "Entry 1", + }).save(); + + const res = await request(app) + .get("/api/v1/time-entries") + .set("Cookie", [accessToken]); + + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(1); + expect(res.body.data[0].description).toBe("Entry 1"); + }); + }); +}); diff --git a/src/modules/time-entry/routes/time-entry.v1.routes.ts b/src/modules/time-entry/routes/time-entry.v1.routes.ts new file mode 100644 index 0000000..ff5e3b1 --- /dev/null +++ b/src/modules/time-entry/routes/time-entry.v1.routes.ts @@ -0,0 +1,53 @@ +import { Router } from "express"; +import validateResource from "@middlewares/validators"; +import authenticate from "@middlewares/authenticate"; +import requireRole from "@middlewares/requireRole"; +import { USER_ROLES } from "@constants"; +import { + createTimeEntrySchema, + startTimeEntrySchema, + updateTimeEntrySchema, +} from "../time-entry.validators"; +import { + startTimeEntry, + stopTimeEntry, + createManualEntry, + getActiveEntry, + listEntries, + updateEntry, + deleteEntry, +} from "../time-entry.controller"; + +const timeEntryRouter = Router(); + +const allApprovedRoles = [ + USER_ROLES.OWNER, + USER_ROLES.MANAGER, + USER_ROLES.MEMBER, + USER_ROLES.VIEWER, +]; + +timeEntryRouter.use(authenticate); +timeEntryRouter.use(requireRole(allApprovedRoles)); + +timeEntryRouter.post( + "/start", + validateResource(startTimeEntrySchema), + startTimeEntry, +); +timeEntryRouter.post("/stop", stopTimeEntry); +timeEntryRouter.post( + "/manual", + validateResource(createTimeEntrySchema), + createManualEntry, +); +timeEntryRouter.get("/active", getActiveEntry); +timeEntryRouter.get("/", listEntries); +timeEntryRouter.patch( + "/:id", + validateResource(updateTimeEntrySchema), + updateEntry, +); +timeEntryRouter.delete("/:id", deleteEntry); + +export default timeEntryRouter; diff --git a/src/modules/time-entry/time-entry.controller.ts b/src/modules/time-entry/time-entry.controller.ts new file mode 100644 index 0000000..a9551f6 --- /dev/null +++ b/src/modules/time-entry/time-entry.controller.ts @@ -0,0 +1,169 @@ +import { Request, Response, NextFunction } from "express"; +import TimeEntryService from "./time-entry.service"; +import { routeTryCatcher } from "@utils/routeTryCatcher"; +import { IUser } from "@modules/user/user.types"; +import { IOrganization } from "@modules/organization/organization.types"; +import AppError from "@utils/AppError"; +import { IErrorPayload, ISuccessPayload } from "src/types"; +import { TimeEntryOutput } from "./time-entry.types"; + +export const startTimeEntry = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const user = req.user as IUser; + const org = req.userOrg as IOrganization; + + const result = await TimeEntryService.startTimeEntry( + user._id.toString(), + org._id.toString(), + req.body, + ); + + if ((result as IErrorPayload).error) { + return next( + AppError.badRequest( + (result as IErrorPayload).error || "Failed to start timer", + ), + ); + } + + return res.status(201).json({ + success: true, + message: "Timer started successfully", + data: (result as ISuccessPayload).data, + }); + }, +); + +export const stopTimeEntry = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const user = req.user as IUser; + + const result = await TimeEntryService.stopTimeEntry(user._id.toString()); + + if ((result as IErrorPayload).error) { + const error = (result as IErrorPayload).error; + if (error === "No active timer found") { + return next(AppError.notFound(error)); + } + return next(AppError.badRequest(error || "Failed to stop timer")); + } + + return res.status(200).json({ + success: true, + message: "Timer stopped successfully", + data: (result as ISuccessPayload).data, + }); + }, +); + +export const createManualEntry = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const user = req.user as IUser; + const org = req.userOrg as IOrganization; + + const result = await TimeEntryService.createManualEntry( + user._id.toString(), + org._id.toString(), + req.body, + ); + + if ((result as IErrorPayload).error) { + return next( + AppError.badRequest( + (result as IErrorPayload).error || "Failed to create manual entry", + ), + ); + } + + return res.status(201).json({ + success: true, + message: "Manual time entry created successfully", + data: (result as ISuccessPayload).data, + }); + }, +); + +export const getActiveEntry = routeTryCatcher( + async (req: Request, res: Response) => { + const user = req.user as IUser; + const activeEntry = await TimeEntryService.getActiveEntry( + user._id.toString(), + ); + + return res.status(200).json({ + success: true, + message: "Active entry retrieved successfully", + data: activeEntry, + }); + }, +); + +export const listEntries = routeTryCatcher( + async (req: Request, res: Response) => { + const user = req.user as IUser; + const org = req.userOrg as IOrganization; + const { projectId, startDate, endDate } = req.query; + + const entries = await TimeEntryService.listEntries( + user._id.toString(), + org._id.toString(), + { + projectId: projectId as string | undefined, + startDate: startDate ? new Date(startDate as string) : undefined, + endDate: endDate ? new Date(endDate as string) : undefined, + }, + ); + + return res.status(200).json({ + success: true, + message: "Time entries retrieved successfully", + data: entries, + }); + }, +); + +export const updateEntry = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const { id } = req.params; + if (!id) return next(AppError.badRequest("Entry ID is required")); + + const result = await TimeEntryService.updateEntry(id, req.body); + + if ((result as IErrorPayload).error) { + const error = (result as IErrorPayload).error; + if (error === "Time entry not found") { + return next(AppError.notFound(error)); + } + return next(AppError.badRequest(error || "Failed to update entry")); + } + + return res.status(200).json({ + success: true, + message: "Time entry updated successfully", + data: (result as ISuccessPayload).data, + }); + }, +); + +export const deleteEntry = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const { id } = req.params; + if (!id) return next(AppError.badRequest("Entry ID is required")); + + const result = await TimeEntryService.deleteEntry(id); + + if ((result as IErrorPayload).error) { + const error = (result as IErrorPayload).error; + if (error === "Time entry not found") { + return next(AppError.notFound(error)); + } + return next(AppError.badRequest(error || "Failed to delete entry")); + } + + return res.status(200).json({ + success: true, + message: "Time entry deleted successfully", + data: null, + }); + }, +); diff --git a/src/modules/time-entry/time-entry.docs.ts b/src/modules/time-entry/time-entry.docs.ts new file mode 100644 index 0000000..b01aeb6 --- /dev/null +++ b/src/modules/time-entry/time-entry.docs.ts @@ -0,0 +1,94 @@ +import { Tspec } from "tspec"; +import { + CreateTimeEntryInput, + StartTimeEntryInput, + UpdateTimeEntryInput, + TimeEntryOutput, +} from "./time-entry.types"; +import { ISuccessPayload, IErrorPayload } from "src/types"; + +export type TimeEntryApiSpec = Tspec.DefineApiSpec<{ + basePath: "/api/v1/time-entries"; + tags: ["Time Entries"]; + paths: { + "/start": { + post: { + summary: "Start a new timer"; + body: StartTimeEntryInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + }; + }; + }; + "/stop": { + post: { + summary: "Stop the currently active timer"; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 404: IErrorPayload; + }; + }; + }; + "/manual": { + post: { + summary: "Create a manual (completed) time entry"; + body: CreateTimeEntryInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + }; + }; + }; + "/active": { + get: { + summary: "Get the currently active timer for the user"; + responses: { + 200: ISuccessPayload; + 401: IErrorPayload; + }; + }; + }; + "/": { + get: { + summary: "List time entries with filters"; + query: { + projectId?: string; + startDate?: string; + endDate?: string; + }; + responses: { + 200: ISuccessPayload; + 401: IErrorPayload; + }; + }; + }; + "/{id}": { + patch: { + summary: "Update a time entry"; + path: { id: string }; + body: UpdateTimeEntryInput; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 404: IErrorPayload; + }; + }; + delete: { + summary: "Delete a time entry"; + path: { id: string }; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 404: IErrorPayload; + }; + }; + }; + }; +}>; diff --git a/src/modules/time-entry/time-entry.model.ts b/src/modules/time-entry/time-entry.model.ts new file mode 100644 index 0000000..56b0f5e --- /dev/null +++ b/src/modules/time-entry/time-entry.model.ts @@ -0,0 +1,80 @@ +import mongoose, { Schema } from "mongoose"; +import { ITimeEntry } from "./time-entry.types"; + +const timeEntrySchema = new Schema( + { + userId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + orgId: { + type: Schema.Types.ObjectId, + ref: "Organization", + required: true, + }, + projectId: { + type: Schema.Types.ObjectId, + ref: "Project", + required: true, + }, + taskId: { + type: Schema.Types.ObjectId, + ref: "Task", + required: true, + }, + description: { + type: String, + trim: true, + default: "", + }, + startTime: { + type: Date, + required: true, + }, + endTime: { + type: Date, + default: null, + }, + duration: { + type: Number, + default: 0, + }, + isBillable: { + type: Boolean, + default: true, + }, + isManual: { + type: Boolean, + default: false, + }, + tags: [ + { + type: Schema.Types.ObjectId, + ref: "Tag", + }, + ], + }, + { + timestamps: true, + }, +); + +// Compound index for fast retrieval of recent entries for a specific user +timeEntrySchema.index({ userId: 1, startTime: -1 }); + +// Pre-save hook to calculate duration in milliseconds +timeEntrySchema.pre("save", function (next) { + const self = this as unknown as ITimeEntry; + if (self.endTime && self.startTime) { + self.duration = + new Date(self.endTime).getTime() - new Date(self.startTime).getTime(); + } else { + self.duration = 0; + } + next(); +}); + +const TimeEntryModel = mongoose.model("TimeEntry", timeEntrySchema); + +export default TimeEntryModel; diff --git a/src/modules/time-entry/time-entry.service.ts b/src/modules/time-entry/time-entry.service.ts new file mode 100644 index 0000000..a9dbde8 --- /dev/null +++ b/src/modules/time-entry/time-entry.service.ts @@ -0,0 +1,273 @@ +import mongoose from "mongoose"; +import TimeEntryModel from "./time-entry.model"; +import UserModel from "@modules/user/user.model"; +import { + ITimeEntry, + CreateTimeEntryInput, + StartTimeEntryInput, + UpdateTimeEntryInput, + TimeEntryOutput, + TimeEntryBase, +} from "./time-entry.types"; +import { ISuccessPayload, IErrorPayload, Mappable } from "src/types"; + +const mapToOutput = (doc: TimeEntryBase & Mappable): TimeEntryOutput => { + return { + id: doc._id.toString(), + userId: doc.userId.toString(), + orgId: doc.orgId.toString(), + projectId: doc.projectId?.toString(), + taskId: doc.taskId?.toString(), + description: doc.description, + startTime: doc.startTime, + endTime: doc.endTime ?? null, + duration: doc.duration, + isBillable: doc.isBillable, + isManual: doc.isManual, + tags: doc.tags?.map((t) => t.toString()) || [], + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; +}; + +const TimeEntryService = { + /** + * Start a new timer. + * If an active timer already exists, it stops it first. + */ + startTimeEntry: async ( + userId: string, + orgId: string, + input: StartTimeEntryInput, + ): Promise | IErrorPayload> => { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + const user = await UserModel.findById(userId).session(session); + if (!user) throw new Error("User not found"); + + // Auto-stop previous timer if exists + if (user.activeTimerId) { + await TimeEntryModel.findByIdAndUpdate( + user.activeTimerId, + { endTime: new Date() }, + { session }, + ); + } + + const startTime = input.startTime + ? new Date(input.startTime) + : new Date(); + + const timeEntry = new TimeEntryModel({ + userId, + orgId, + projectId: input.projectId, + taskId: input.taskId, + description: input.description, + startTime, + endTime: null, + isBillable: input.isBillable ?? true, + isManual: false, + tags: input.tags, + }); + + await timeEntry.save({ session }); + + user.activeTimerId = timeEntry._id as mongoose.Types.ObjectId; + await user.save({ session }); + + await session.commitTransaction(); + + return { + success: true, + data: mapToOutput(timeEntry as unknown as TimeEntryBase & Mappable), + }; + } catch (err) { + if (session.inTransaction()) await session.abortTransaction(); + return { success: false, error: (err as Error).message }; + } finally { + session.endSession(); + } + }, + + /** + * Stop the currently active timer. + */ + stopTimeEntry: async ( + userId: string, + ): Promise | IErrorPayload> => { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + const user = await UserModel.findById(userId).session(session); + if (!user || !user.activeTimerId) { + throw new Error("No active timer found"); + } + + const timeEntry = await TimeEntryModel.findById( + user.activeTimerId, + ).session(session); + if (!timeEntry) throw new Error("Active time entry not found"); + + timeEntry.endTime = new Date(); + await timeEntry.save({ session }); + + user.activeTimerId = null; + await user.save({ session }); + + await session.commitTransaction(); + + return { + success: true, + data: mapToOutput(timeEntry as unknown as TimeEntryBase & Mappable), + }; + } catch (err) { + if (session.inTransaction()) await session.abortTransaction(); + return { success: false, error: (err as Error).message }; + } finally { + session.endSession(); + } + }, + + /** + * Create a manual time entry (already completed). + */ + createManualEntry: async ( + userId: string, + orgId: string, + input: CreateTimeEntryInput, + ): Promise | IErrorPayload> => { + try { + const timeEntry = new TimeEntryModel({ + userId, + orgId, + projectId: input.projectId, + taskId: input.taskId, + description: input.description, + startTime: new Date(input.startTime), + endTime: input.endTime ? new Date(input.endTime) : new Date(), + isBillable: input.isBillable ?? true, + isManual: true, + tags: input.tags, + }); + + await timeEntry.save(); + + return { + success: true, + data: mapToOutput(timeEntry as unknown as TimeEntryBase & Mappable), + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }, + + /** + * Get the active time entry for a user. + */ + getActiveEntry: async (userId: string): Promise => { + const user = await UserModel.findById(userId) + .select("activeTimerId") + .lean(); + if (!user?.activeTimerId) return null; + + const entry = await TimeEntryModel.findById(user.activeTimerId).lean(); + return entry + ? mapToOutput(entry as unknown as TimeEntryBase & Mappable) + : null; + }, + + /** + * List time entries with filters. + */ + listEntries: async ( + userId: string, + orgId: string, + filters: { + projectId?: string | undefined; + startDate?: Date | undefined; + endDate?: Date | undefined; + } = {}, + ): Promise => { + const query: mongoose.FilterQuery = { userId, orgId }; + + if (filters.projectId) + query.projectId = new mongoose.Types.ObjectId(filters.projectId); + if (filters.startDate || filters.endDate) { + query.startTime = {}; + if (filters.startDate) query.startTime.$gte = filters.startDate; + if (filters.endDate) query.startTime.$lte = filters.endDate; + } + + const entries = await TimeEntryModel.find(query) + .sort({ startTime: -1 }) + .lean(); + return entries.map((doc) => + mapToOutput(doc as unknown as TimeEntryBase & Mappable), + ); + }, + + getEntryById: async (id: string): Promise => { + return await TimeEntryModel.findById(id); + }, + + updateEntry: async ( + id: string, + input: UpdateTimeEntryInput, + ): Promise | IErrorPayload> => { + try { + const timeEntry = await TimeEntryModel.findById(id); + if (!timeEntry) return { success: false, error: "Time entry not found" }; + + if (input.projectId !== undefined) { + timeEntry.projectId = + input.projectId as unknown as mongoose.Types.ObjectId; + } + if (input.taskId !== undefined) { + timeEntry.taskId = input.taskId as unknown as mongoose.Types.ObjectId; + } + if (input.description !== undefined) { + timeEntry.description = input.description; + } + if (input.startTime !== undefined) { + timeEntry.startTime = new Date(input.startTime); + } + if (input.endTime !== undefined) { + timeEntry.endTime = input.endTime ? new Date(input.endTime) : null; + } + if (input.isBillable !== undefined) { + timeEntry.isBillable = input.isBillable; + } + if (input.tags !== undefined) { + timeEntry.tags = input.tags as unknown as mongoose.Types.ObjectId[]; + } + + await timeEntry.save(); + + return { + success: true, + data: mapToOutput(timeEntry as unknown as TimeEntryBase & Mappable), + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }, + + deleteEntry: async ( + id: string, + ): Promise | IErrorPayload> => { + try { + const result = await TimeEntryModel.findByIdAndDelete(id); + if (!result) return { success: false, error: "Time entry not found" }; + + return { success: true, data: null }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }, +}; + +export default TimeEntryService; diff --git a/src/modules/time-entry/time-entry.types.ts b/src/modules/time-entry/time-entry.types.ts new file mode 100644 index 0000000..d3e1573 --- /dev/null +++ b/src/modules/time-entry/time-entry.types.ts @@ -0,0 +1,66 @@ +import mongoose from "mongoose"; + +export interface TimeEntryBase { + userId: mongoose.Types.ObjectId; + orgId: mongoose.Types.ObjectId; + projectId: mongoose.Types.ObjectId; + taskId: mongoose.Types.ObjectId; + description: string; + startTime: Date; + endTime?: Date | null; + duration?: number; + isBillable: boolean; + isManual: boolean; + tags: mongoose.Types.ObjectId[]; +} + +export interface ITimeEntry extends TimeEntryBase, mongoose.Document { + createdAt: Date; + updatedAt: Date; +} + +export interface TimeEntryOutput { + id: string; + userId: string; + orgId: string; + projectId?: string | undefined; + taskId?: string | undefined; + description: string; + startTime: Date; + endTime?: Date | null | undefined; + duration?: number | undefined; + isBillable: boolean; + isManual: boolean; + tags: string[]; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateTimeEntryInput { + projectId: string; + taskId: string; + description?: string; + startTime: string; // ISO String + endTime?: string; // ISO String + isBillable?: boolean; + tags?: string[]; +} + +export interface StartTimeEntryInput { + projectId: string; + taskId: string; + description?: string; + startTime?: string; // ISO String, defaults to now + isBillable?: boolean; + tags?: string[]; +} + +export interface UpdateTimeEntryInput { + projectId?: string; + taskId?: string; + description?: string; + startTime?: string; + endTime?: string; + isBillable?: boolean; + tags?: string[]; +} diff --git a/src/modules/time-entry/time-entry.validators.ts b/src/modules/time-entry/time-entry.validators.ts new file mode 100644 index 0000000..993c2bb --- /dev/null +++ b/src/modules/time-entry/time-entry.validators.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; +import { zObjectId } from "@utils/validators"; + +export const createTimeEntrySchema = z + .object({ + projectId: zObjectId, + taskId: zObjectId, + description: z.string().max(500).optional(), + startTime: z.string().datetime(), + endTime: z.string().datetime().optional().nullable(), + isBillable: z.boolean().optional(), + tags: z.array(zObjectId).optional(), + }) + .refine( + (data) => { + if (data.startTime && data.endTime) { + return new Date(data.endTime) > new Date(data.startTime); + } + return true; + }, + { + message: "End time must be after start time", + path: ["endTime"], + }, + ); + +export const startTimeEntrySchema = z.object({ + projectId: zObjectId, + taskId: zObjectId, + description: z.string().max(500).optional(), + startTime: z.string().datetime().optional(), + isBillable: z.boolean().optional(), + tags: z.array(zObjectId).optional(), +}); + +export const updateTimeEntrySchema = z + .object({ + projectId: zObjectId.optional(), + taskId: zObjectId.optional(), + description: z.string().max(500).optional(), + startTime: z.string().datetime().optional(), + endTime: z.string().datetime().optional().nullable(), + isBillable: z.boolean().optional(), + tags: z.array(zObjectId).optional(), + }) + .refine( + (data) => { + if (data.startTime && data.endTime) { + return new Date(data.endTime) > new Date(data.startTime); + } + return true; + }, + { + message: "End time must be after start time", + path: ["endTime"], + }, + ); diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index 622057e..8a74fa7 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -14,6 +14,11 @@ const userSchema = new Schema( emailVerificationCodeExpiry: { type: Date, default: null }, passwordResetCode: { type: String, default: null }, passwordResetCodeExpiry: { type: Date, default: null }, + activeTimerId: { + type: Schema.Types.ObjectId, + ref: "TimeEntry", + default: null, + }, }, { timestamps: true }, ); diff --git a/src/modules/user/user.types.ts b/src/modules/user/user.types.ts index f7b4a65..b96ca73 100644 --- a/src/modules/user/user.types.ts +++ b/src/modules/user/user.types.ts @@ -14,6 +14,7 @@ export interface IUser extends mongoose.Document { emailVerificationCodeExpiry?: Date | null; passwordResetCode?: string | null; passwordResetCodeExpiry?: Date | null; + activeTimerId?: mongoose.Types.ObjectId | null; generateEmailVerificationCode: () => string; verifyEmailVerificationCode: (code: string) => boolean; clearEmailVerificationData: () => Promise; diff --git a/src/routes/v1.route.ts b/src/routes/v1.route.ts index 35d8dd0..a41da93 100644 --- a/src/routes/v1.route.ts +++ b/src/routes/v1.route.ts @@ -2,10 +2,20 @@ import { Router } from "express"; import authRouter from "@modules/auth/routes/auth.v1.routes"; import organizationRouter from "@modules/organization/routes/organization.v1.routes"; import membershipRouter from "@modules/membership/routes/membership.v1.routes"; +import tagRouter from "@modules/tag/routes/tag.v1.routes"; +import clientRouter from "@modules/client/routes/client.v1.routes"; +import projectRouter from "@modules/project/routes/project.v1.routes"; +import taskRouter from "@modules/task/routes/task.v1.routes"; +import timeEntryRouter from "@modules/time-entry/routes/time-entry.v1.routes"; const v1Router = Router(); v1Router.use("/auth", authRouter); v1Router.use("/org", organizationRouter); v1Router.use("/membership", membershipRouter); +v1Router.use("/tags", tagRouter); +v1Router.use("/clients", clientRouter); +v1Router.use("/projects", projectRouter); +v1Router.use("/tasks", taskRouter); +v1Router.use("/time-entries", timeEntryRouter); export default v1Router; diff --git a/src/tests/helpers/seed.ts b/src/tests/helpers/seed.ts index e88a576..ab7f319 100644 --- a/src/tests/helpers/seed.ts +++ b/src/tests/helpers/seed.ts @@ -1,14 +1,46 @@ import UserModel from "@modules/user/user.model"; import OrganizationModel from "@modules/organization/organization.model"; +import MembershipModel from "@modules/membership/membership.model"; import { UserFactory } from "@tests/factories/user.factory"; import { OrganizationFactory } from "@tests/factories/organization.factory"; import { IUser } from "@modules/user/user.types"; import { IOrganization } from "@modules/organization/organization.types"; import { retryOperation } from "@tests/utils"; +import { UserRole } from "@constants"; + +export const seedUserInOrg = async ( + orgId: string, + userOverrides: Partial | undefined = {}, + role: UserRole = "MEMBER", +) => { + return retryOperation(async () => { + const userData = { + ...UserFactory.generate(), + organization: orgId, + isEmailVerified: true, + ...userOverrides, + }; + + const user = new UserModel(userData); + const membership = new MembershipModel({ + orgId: orgId, + userId: user._id, + role: role, + status: "ACTIVE", + joinedAt: new Date(), + }); + + await user.save(); + await membership.save(); + + return { user, membership }; + }); +}; export const seedOneUserWithOrg = async ( userOverrides: Partial | undefined = {}, orgOverrides: Partial | undefined = {}, + role: UserRole = "OWNER", ) => { return retryOperation(async () => { const organization = new OrganizationModel({ @@ -24,10 +56,21 @@ export const seedOneUserWithOrg = async ( }; const user = new UserModel(userData); - organization.owner = user._id; + organization.owner = orgOverrides.owner || user._id; + + const membership = new MembershipModel({ + orgId: organization._id, + userId: user._id, + role: role, + status: "ACTIVE", + joinedAt: new Date(), + }); + await organization.save(); await user.save(); - return { user, organization }; + await membership.save(); + + return { user, organization, membership }; }); }; diff --git a/src/types/express.d.ts b/src/types/express.d.ts index 6f14cc5..3857c35 100644 --- a/src/types/express.d.ts +++ b/src/types/express.d.ts @@ -1,9 +1,13 @@ import { IUser } from "@modules/user/user.types"; +import { IOrganization } from "@modules/organization/organization.types"; +import { MembershipRole } from "@modules/membership/membership.types"; declare global { namespace Express { interface Request { user?: IUser; + userOrg?: IOrganization; + userRole?: MembershipRole; } } } diff --git a/src/types/index.ts b/src/types/index.ts index 46c3180..045ebe5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -15,3 +15,9 @@ export interface ISuccessPayload { success: boolean; data: T; } + +export interface Mappable { + _id: { toString(): string }; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/utils/validators.ts b/src/utils/validators.ts new file mode 100644 index 0000000..1f72186 --- /dev/null +++ b/src/utils/validators.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +/** + * Regex for MongoDB ObjectId + */ +export const objectIdRegex = /^[0-9a-fA-F]{24}$/; + +/** + * Zod schema for validated MongoDB ObjectId strings + */ +export const zObjectId = z.string().regex(objectIdRegex, "Invalid ID format"); diff --git a/tsconfig.json b/tsconfig.json index a016fb2..db2bfb5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,8 @@ "@config/*": ["src/config/*"], "@middlewares/*": ["src/middlewares/*"], "@docs/*": ["src/docs/*"], - "@services/*": ["src/services/*"] + "@services/*": ["src/services/*"], + "@constants": ["src/constants/index"] } }, "include": ["src", "tests"] From 825ddbe47895799113f6dd5ebfb323c98b57ff26 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Wed, 4 Mar 2026 06:25:16 +0100 Subject: [PATCH 09/12] feat: add Phase 4 - Docker, Render config, and structured logging with pino - Add Dockerfile (multi-stage build) and docker-compose.yml (API + MongoDB) - Add .dockerignore and render.yaml (Render IaC blueprint) - Replace console.log/error with pino structured logging - Replace morgan with pino-http for HTTP request logging - Add server-side error logging in errorHandler - Remove debug console.log in membership.service - Uninstall morgan, install pino/pino-http/pino-pretty --- .dockerignore | 13 + Dockerfile | 32 ++ docker-compose.yml | 21 + package-lock.json | 390 +++++++++++++++---- package.json | 6 +- prioritized_checklist.md | 4 +- render.yaml | 38 ++ src/app.ts | 4 +- src/config/db.ts | 5 +- src/config/logger.ts | 28 ++ src/middlewares/errorHandler.ts | 4 + src/modules/membership/membership.service.ts | 2 - src/server.ts | 3 +- 13 files changed, 458 insertions(+), 92 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 render.yaml create mode 100644 src/config/logger.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8562f2b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +dist +.env +.git +.github +.husky +*.md +jest.config.mjs +eslint.config.mjs +.prettierrc +openapi.json +**/__tests__ +src/tests diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..24a4a22 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# ── Stage 1: Build ──────────────────────────────────────────── +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install dependencies first (layer caching) +COPY package.json package-lock.json ./ +RUN npm ci + +# Copy source and compile TypeScript + resolve path aliases +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +# ── Stage 2: Production ────────────────────────────────────── +FROM node:20-alpine + +WORKDIR /app + +# Install only production dependencies +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force + +# Copy compiled output from builder +COPY --from=builder /app/dist ./dist + +# Run as non-root for security +USER node + +EXPOSE 5000 + +CMD ["node", "dist/server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2c61b6b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + mongo: + image: mongo:7 + ports: + - "27017:27017" + volumes: + - mongo_data:/data/db + + api: + build: . + ports: + - "5000:5000" + depends_on: + - mongo + env_file: + - .env + environment: + - MONGODB_URI=mongodb://mongo:27017/timesheets + +volumes: + mongo_data: diff --git a/package-lock.json b/package-lock.json index cd03d2f..c83f90c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "dependencies": { "@faker-js/faker": "^10.1.0", "@types/cookie-parser": "^1.4.10", - "@types/morgan": "^1.9.10", "bcryptjs": "^3.0.2", "cookie": "^1.1.1", "cookie-parser": "^1.4.7", @@ -23,7 +22,8 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "mongoose": "^8.5.0", - "morgan": "^1.10.1", + "pino": "^10.3.1", + "pino-http": "^11.0.0", "swagger-ui-express": "^5.0.1", "tsconfig-paths": "^4.2.0", "tspec": "^0.1.116", @@ -39,12 +39,14 @@ "@types/helmet": "^0.0.48", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", + "@types/pino-http": "^5.8.4", "@types/supertest": "^6.0.3", "@types/swagger-ui-express": "^4.1.8", "eslint": "^9.37.0", "husky": "^8.0.0", "jest": "^30.2.0", "mongodb-memory-server": "^10.2.3", + "pino-pretty": "^13.1.3", "prettier": "^3.7.4", "supertest": "^7.1.4", "ts-jest": "^29.4.5", @@ -1639,6 +1641,12 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1963,15 +1971,6 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "license": "MIT" }, - "node_modules/@types/morgan": { - "version": "1.9.10", - "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", - "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -1988,6 +1987,60 @@ "undici-types": "~7.14.0" } }, + "node_modules/@types/pino": { + "version": "6.3.12", + "resolved": "https://registry.npmjs.org/@types/pino/-/pino-6.3.12.tgz", + "integrity": "sha512-dsLRTq8/4UtVSpJgl9aeqHvbh6pzdmjYD3C092SYgLD2TyoCqHpTJk6vp8DvCTGGc7iowZ2MoiYiVUUCcu7muw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/pino-pretty": "*", + "@types/pino-std-serializers": "*", + "sonic-boom": "^2.1.0" + } + }, + "node_modules/@types/pino-http": { + "version": "5.8.4", + "resolved": "https://registry.npmjs.org/@types/pino-http/-/pino-http-5.8.4.tgz", + "integrity": "sha512-UTYBQ2acmJ2eK0w58vVtgZ9RAicFFndfrnWC1w5cBTf8zwn/HEy8O+H7psc03UZgTzHmlcuX8VkPRnRDEj+FUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pino": "6.3" + } + }, + "node_modules/@types/pino-pretty": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/@types/pino-pretty/-/pino-pretty-4.7.5.tgz", + "integrity": "sha512-rfHe6VIknk14DymxGqc9maGsRe8/HQSvM2u46EAz2XrS92qsAJnW16dpdFejBuZKD8cRJX6Aw6uVZqIQctMpAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/pino": "6.3" + } + }, + "node_modules/@types/pino-std-serializers": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@types/pino-std-serializers/-/pino-std-serializers-2.4.1.tgz", + "integrity": "sha512-17XcksO47M24IVTVKPeAByWUd3Oez7EbIjXpSbzMPhXVzgjGtrOa49gKBwxH9hb8dKv58OelsWQ+A1G1l9S3wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/pino/node_modules/sonic-boom": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-2.8.0.tgz", + "integrity": "sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -2833,6 +2886,15 @@ "dev": true, "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/b4a": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", @@ -2978,24 +3040,6 @@ "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/basic-auth": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", - "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.1.2" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/basic-auth/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, "node_modules/bcryptjs": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", @@ -3385,6 +3429,13 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3540,6 +3591,16 @@ "node": ">= 8" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3754,6 +3815,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -4178,6 +4249,13 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/fast-copy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", + "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4803,6 +4881,13 @@ "node": ">=18.0.0" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -5931,6 +6016,16 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6510,49 +6605,6 @@ "url": "https://opencollective.com/mongoose" } }, - "node_modules/morgan": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", - "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", - "license": "MIT", - "dependencies": { - "basic-auth": "~2.0.1", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-finished": "~2.3.0", - "on-headers": "~1.1.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/morgan/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/morgan/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/morgan/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/mpath": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", @@ -6746,6 +6798,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6758,15 +6819,6 @@ "node": ">= 0.8" } }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7014,6 +7066,93 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-http": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-11.0.0.tgz", + "integrity": "sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^10.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -7160,6 +7299,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -7173,6 +7328,17 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7245,6 +7411,12 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -7289,6 +7461,15 @@ "node": ">=8.10.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7452,6 +7633,23 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -7651,6 +7849,15 @@ "node": ">=8" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7681,6 +7888,15 @@ "memory-pager": "^1.0.2" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -7985,6 +8201,18 @@ "b4a": "^1.6.4" } }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/package.json b/package.json index a56d141..632e76d 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "dependencies": { "@faker-js/faker": "^10.1.0", "@types/cookie-parser": "^1.4.10", - "@types/morgan": "^1.9.10", "bcryptjs": "^3.0.2", "cookie": "^1.1.1", "cookie-parser": "^1.4.7", @@ -30,7 +29,8 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "mongoose": "^8.5.0", - "morgan": "^1.10.1", + "pino": "^10.3.1", + "pino-http": "^11.0.0", "swagger-ui-express": "^5.0.1", "tsconfig-paths": "^4.2.0", "tspec": "^0.1.116", @@ -46,12 +46,14 @@ "@types/helmet": "^0.0.48", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", + "@types/pino-http": "^5.8.4", "@types/supertest": "^6.0.3", "@types/swagger-ui-express": "^4.1.8", "eslint": "^9.37.0", "husky": "^8.0.0", "jest": "^30.2.0", "mongodb-memory-server": "^10.2.3", + "pino-pretty": "^13.1.3", "prettier": "^3.7.4", "supertest": "^7.1.4", "ts-jest": "^29.4.5", diff --git a/prioritized_checklist.md b/prioritized_checklist.md index f6fe02d..abd05ab 100644 --- a/prioritized_checklist.md +++ b/prioritized_checklist.md @@ -26,8 +26,8 @@ This checklist breaks down the [Implementation Plan](file:///Users/exploit/Deskt - [x] Prevent multiple active timers per user. ### Phase 4: Observability & DevOps -- [ ] **Structured Logging**: Integrate `pino` or `winston` and replace existing `console.log` calls. -- [ ] **Dockerization**: Create a `Dockerfile` and `docker-compose.yml` with MongoDB environment setup. +- [x] **Structured Logging**: Integrate `pino` or `winston` and replace existing `console.log` calls. +- [x] **Dockerization**: Create a `Dockerfile` and `docker-compose.yml` with MongoDB environment setup. ### Phase 5: Advanced Features - [ ] **Reporting Engine**: diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..39a529a --- /dev/null +++ b/render.yaml @@ -0,0 +1,38 @@ +services: + - type: web + name: timesheets-api + runtime: docker + plan: free + branch: main + healthCheckPath: /health + envVars: + - key: NODE_ENV + value: production + - key: PORT + value: "5000" + - key: MONGODB_URI + sync: false + - key: JWT_SECRET + sync: false + - key: JWT_ACCESS_EXPIRES_IN + sync: false + - key: JWT_REFRESH_EXPIRES_IN + sync: false + - key: REFRESH_TOKEN_BYTES + sync: false + - key: COOKIE_SECRET + sync: false + - key: ZEPTO_MAIL_TOKEN + sync: false + - key: ZEPTO_MAIL_URL + sync: false + - key: FROM_EMAIL + sync: false + - key: SUPPORT_EMAIL + sync: false + - key: FROM_NAME + sync: false + - key: EMAIL_VERIFICATION_TEMPLATE_KEY + sync: false + - key: PASSWORD_RESET_TEMPLATE_KEY + sync: false diff --git a/src/app.ts b/src/app.ts index 764fa90..d65f394 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,7 +2,7 @@ import express, { Application, NextFunction, Request, Response } from "express"; import helmet from "helmet"; import { rateLimit } from "express-rate-limit"; import cors from "cors"; -import morgan from "morgan"; +import { httpLogger } from "@config/logger"; import v1Router from "./routes/v1.route"; import errorHandler from "./middlewares/errorHandler"; import cookieParser from "cookie-parser"; @@ -39,7 +39,7 @@ app.use( ); app.use(cookieParser(COOKIE_SECRET)); app.use(express.json()); -app.use(morgan("dev")); +app.use(httpLogger); app.use("/api/v1", v1Router); export function mountSwagger(spec: object) { diff --git a/src/config/db.ts b/src/config/db.ts index 592088e..dc02525 100644 --- a/src/config/db.ts +++ b/src/config/db.ts @@ -1,12 +1,13 @@ import mongoose from "mongoose"; import { MONGODB_URI } from "./env"; +import { logger } from "./logger"; const connectDB = async () => { try { const conn = await mongoose.connect(MONGODB_URI as string); - console.log(`✅ MongoDB Connected: ${conn.connection.host}`); + logger.info({ host: conn.connection.host }, "MongoDB connected"); } catch (error) { - console.error("🚨 MongoDB connection failed:", error); + logger.fatal({ err: error }, "MongoDB connection failed"); process.exit(1); } }; diff --git a/src/config/logger.ts b/src/config/logger.ts new file mode 100644 index 0000000..899ff42 --- /dev/null +++ b/src/config/logger.ts @@ -0,0 +1,28 @@ +import pino from "pino"; +import pinoHttp from "pino-http"; + +const isProduction = process.env.NODE_ENV === "production"; +const isTest = process.env.NODE_ENV === "test"; + +export const logger = pino({ + level: isTest ? "silent" : isProduction ? "info" : "debug", + ...(isProduction + ? {} + : { + transport: { + target: "pino-pretty", + options: { + colorize: true, + translateTime: "HH:MM:ss", + ignore: "pid,hostname", + }, + }, + }), +}); + +export const httpLogger = pinoHttp({ + logger, + autoLogging: { + ignore: (req) => (req.url ?? "").includes("/health"), + }, +}); diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts index 0652497..7226c6d 100644 --- a/src/middlewares/errorHandler.ts +++ b/src/middlewares/errorHandler.ts @@ -1,6 +1,7 @@ import { NextFunction, Request, Response } from "express"; import AppError from "../utils/AppError"; import { NODE_ENV } from "@config/env"; +import { logger } from "@config/logger"; import { MongooseError } from "mongoose"; const errorHandler = ( @@ -19,12 +20,15 @@ const errorHandler = ( } if (err instanceof Error || err instanceof MongooseError) { + logger.error({ err }, "Unhandled error"); return res.status(500).json({ success: false, error: err.message || "Something went wrong", stack: NODE_ENV === "development" ? err.stack : "", }); } + + logger.error({ err }, "Unhandled non-Error thrown"); return res.status(500).json({ success: false, error: "Something went wrong", diff --git a/src/modules/membership/membership.service.ts b/src/modules/membership/membership.service.ts index 13e4f9c..e4e5e9a 100644 --- a/src/modules/membership/membership.service.ts +++ b/src/modules/membership/membership.service.ts @@ -48,8 +48,6 @@ const MembershipService = { await membership.save({ session: session || null }); - if (input.email === "inviteeone@example.com") - console.log(membership, "inviteeone@example.com"); return { success: true, data: { diff --git a/src/server.ts b/src/server.ts index 5c8f70c..8f83dfc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,7 @@ dotenv.config(); import app, { mountSwagger } from "./app"; import connectDB from "./config/db"; import { PORT } from "@config/env"; +import { logger } from "@config/logger"; import { getTSpec } from "@docs/tspecGenerator"; import { notFound } from "@middlewares/notFound"; @@ -13,7 +14,7 @@ async function start() { app.use("*", notFound); app.listen(PORT, () => { - console.log(`🚀 Server running on http://localhost:${PORT}`); + logger.info({ port: PORT }, "Server running"); }); } From 61e4c68a4d59045d97003e305cb7d10175ff0680 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Wed, 4 Mar 2026 06:39:53 +0100 Subject: [PATCH 10/12] ci: optimize CI to run affected tests on PRs and full suite on push --- .github/workflows/node.js.yml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index cb92201..6c06671 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -1,5 +1,4 @@ -# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs +# Timesheets CI — runs affected tests on PRs, full suite on merge to main/develop name: Timesheets CI @@ -15,16 +14,29 @@ jobs: strategy: matrix: - node-version: [18.x, 20.x, 22.x] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + node-version: [20.x] steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 # needed for --changedSince to compare branches + - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: "npm" + - run: npm ci - run: npm run build --if-present - - run: npm test + + # PRs: only run tests affected by changed files + - name: Run affected tests (PR) + if: github.event_name == 'pull_request' + run: npx jest --runInBand --changedSince=origin/${{ github.base_ref }} + + # Pushes to main/develop: run the full test suite + - name: Run all tests (push) + if: github.event_name == 'push' + run: npm test + From 549e8ef2b8c6450df3d40d3338612c361982b253 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Thu, 5 Mar 2026 05:26:07 +0100 Subject: [PATCH 11/12] Update render.yaml --- render.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/render.yaml b/render.yaml index 39a529a..d96320d 100644 --- a/render.yaml +++ b/render.yaml @@ -4,7 +4,7 @@ services: runtime: docker plan: free branch: main - healthCheckPath: /health + healthCheckPath: /api/health envVars: - key: NODE_ENV value: production From dc2fe827efc735a20377d8aaf45ec4c031634cea Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Thu, 5 Mar 2026 06:17:05 +0100 Subject: [PATCH 12/12] Install pino pretty as a dep --- package-lock.json | 13 +------------ package.json | 4 ++-- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index c83f90c..ab3b497 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "mongoose": "^8.5.0", "pino": "^10.3.1", "pino-http": "^11.0.0", + "pino-pretty": "^13.1.3", "swagger-ui-express": "^5.0.1", "tsconfig-paths": "^4.2.0", "tspec": "^0.1.116", @@ -46,7 +47,6 @@ "husky": "^8.0.0", "jest": "^30.2.0", "mongodb-memory-server": "^10.2.3", - "pino-pretty": "^13.1.3", "prettier": "^3.7.4", "supertest": "^7.1.4", "ts-jest": "^29.4.5", @@ -3433,7 +3433,6 @@ "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -3595,7 +3594,6 @@ "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -3819,7 +3817,6 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -4253,7 +4250,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==", - "dev": true, "license": "MIT" }, "node_modules/fast-deep-equal": { @@ -4318,7 +4314,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true, "license": "MIT" }, "node_modules/fastq": { @@ -4885,7 +4880,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", - "dev": true, "license": "MIT" }, "node_modules/html-escaper": { @@ -6020,7 +6014,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -7113,7 +7106,6 @@ "version": "13.1.3", "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", - "dev": true, "license": "MIT", "dependencies": { "colorette": "^2.0.7", @@ -7138,7 +7130,6 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", - "dev": true, "license": "MIT", "engines": { "node": ">=14.16" @@ -7332,7 +7323,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -7637,7 +7627,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", - "dev": true, "funding": [ { "type": "github", diff --git a/package.json b/package.json index 632e76d..08529ef 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "tsconfig-paths": "^4.2.0", "tspec": "^0.1.116", "zeptomail": "^6.2.1", - "zod": "^3.23.8" + "zod": "^3.23.8", + "pino-pretty": "^13.1.3" }, "devDependencies": { "@eslint/js": "^9.37.0", @@ -53,7 +54,6 @@ "husky": "^8.0.0", "jest": "^30.2.0", "mongodb-memory-server": "^10.2.3", - "pino-pretty": "^13.1.3", "prettier": "^3.7.4", "supertest": "^7.1.4", "ts-jest": "^29.4.5",