diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95c3690..6a617b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, dev] pull_request: jobs: @@ -27,6 +27,9 @@ jobs: - name: Verify pnpm run: pnpm -v + - name: Force npm registry + run: pnpm config set registry https://registry.npmjs.org/ + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.gitignore b/.gitignore index 4f9c061..bdc65ec 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ node_modules .env.* dist .next -!.env.example \ No newline at end of file +!.env.example +seed_test.ts \ No newline at end of file diff --git a/apps/core-api/package.json b/apps/core-api/package.json index 4f9827a..f7df465 100644 --- a/apps/core-api/package.json +++ b/apps/core-api/package.json @@ -16,24 +16,34 @@ "license": "ISC", "packageManager": "pnpm@10.27.0", "dependencies": { + "@aws-sdk/client-s3": "^3.1006.0", + "@aws-sdk/s3-request-presigner": "^3.1006.0", "amqplib": "^0.10.9", "bcrypt": "^6.0.0", + "bullmq": "^5.76.0", + "cookie-parser": "^1.4.7", "dotenv": "^17.2.3", "express": "^5.2.1", - "ioredis": "^5.9.2", + "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.3", "meilisearch": "^0.55.0", "mongoose": "^9.1.5", "morgan": "^1.10.1", + "node-cron": "^4.2.1", + "nodemailer": "^8.0.1", + "razorpay": "^2.9.6", + "stripe": "^21.0.1", "winston": "^3.19.0" }, "devDependencies": { "@types/amqplib": "^0.10.8", "@types/bcrypt": "^6.0.0", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.6", "@types/jsonwebtoken": "^9.0.10", "@types/morgan": "^1.9.10", "@types/node": "^25.0.10", + "@types/nodemailer": "^7.0.9", "nodemon": "^3.1.11", "ts-node": "^10.9.2", "tsx": "^4.21.0", diff --git a/apps/core-api/src/controllers/admin.controller.ts b/apps/core-api/src/controllers/admin.controller.ts new file mode 100644 index 0000000..8641918 --- /dev/null +++ b/apps/core-api/src/controllers/admin.controller.ts @@ -0,0 +1,221 @@ +import type { Request, Response, NextFunction } from "express"; + +import { + approveSignupService, + getAllPendingSignupService, + rejectSignupService, + getTotalUsersService, +} from "../services/verification.service.js"; +import { getTotalGigsService } from "../services/gig.service.js"; +import { + getRecentActivityService, + getGigModerationStatsService, + pauseGigService, + ignoreGigService, + rejectGigService, + getBookingsAuditService +} from "../services/admin.service.js"; +import { PlatformRevenueModel } from "../models/platform-revenue.model.js"; +import type { AuthRequest } from "../middlewares/auth.middleware.js"; + +export const getPlatformRevenueController = async (req: Request, res: Response, next: NextFunction) => { + try { + const platform = await PlatformRevenueModel.findOne(); + const revenue = platform ? platform.totalRevenue : 0; + res.status(200).json({ success: true, data: revenue }); + } catch (error) { + next(error); + } +}; +export const getTotalGigsController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const count = await getTotalGigsService() + res.status(200).json({ success: true, data: count }) + } catch (error) { + next(error) + } +} + +export const getTotalUsersController = async ( + req: Request, + res: Response, + next: NextFunction) => { + try { + const count = await getTotalUsersService() + res.status(200).json({ success: true, data: count }); + } catch (error) { + next(error) + } + +} + +export const approveSignupController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { email } = req.body; + const result = await approveSignupService(email); + res.status(200).json({ success: true, data: result }); + } catch (error) { + next(error); + } +}; + +export const rejectSignupController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { email, reason } = req.body; + const result = await rejectSignupService(email, reason); + res.status(200).json({ success: true, data: result }); + } catch (error) { + next(error); + } +}; + +export const getAllpendingSignupController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const requests = await getAllPendingSignupService(req.query); + res.status(200).json({ success: true, data: requests }); + } catch (error) { + next(error); + } +}; + +export const getRecentActivityController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const limit = parseInt(req.query.limit as string) || 10; + const activities = await getRecentActivityService(limit); + res.status(200).json({ success: true, data: activities }); + } catch (error) { + next(error); + } +}; + +export const getGigModerationStatsController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const stats = await getGigModerationStatsService(); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } +}; + +//=================PAUSE GIG================= +export const pauseGigController = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const gigId = req.params.gigId as string; + const { reportId, reason } = req.body; + + const result = await pauseGigService( + gigId, + req.user!.userId, + reportId, + reason + ); + + res.status(200).json({ + success: true, + message: "Gig paused successfully", + data: result + }); + + } catch (error) { + next(error); + } +}; + + + +//===================IGNORE GIG=================== +export const ignoreGigController = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const gigId = req.params.gigId as string; + const { reportId, reason } = req.body; + + const result = await ignoreGigService( + gigId, + req.user!.userId, + reportId, + reason + ); + + res.status(200).json({ + success: true, + message: "Reports ignored, gig restored", + data: result + }); + + } catch (error) { + next(error); + } +}; + +//===================REJECT GIG=================== +export const rejectGigController = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const gigId = req.params.gigId as string; + const { reportId, reason } = req.body; + + const result = await rejectGigService( + gigId, + req.user!.userId, + reportId, + reason + ); + + res.status(200).json({ + success: true, + message: "Gig rejected", + data: result + }); + + } catch (error) { + next(error); + } +}; + +export const getBookingsAuditController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const data = await getBookingsAuditService(); + res.status(200).json({ success: true, data }); + } catch (error) { + next(error); + } +}; \ No newline at end of file diff --git a/apps/core-api/src/controllers/auth.controller.ts b/apps/core-api/src/controllers/auth.controller.ts index 65ba41f..110ee65 100644 --- a/apps/core-api/src/controllers/auth.controller.ts +++ b/apps/core-api/src/controllers/auth.controller.ts @@ -1,9 +1,57 @@ -import type { NextFunction, Request, Response } from "express"; +import type { NextFunction, Request, Response } from "express"; -import { loginService } from "../services/auth.service.js"; +import { + logoutService, + refreshTokenService, + loginService, + signupService, + forgotPasswordService, + verifyOtpService, + resetPasswordService, + verifySignupOtpService, + resendSignupOtpService, + pendingProfileService, +} from "../services/auth.service.js"; import type { HttpError } from "../modules/auth/http-error.js"; +// import { log } from "winston"; +// ================= SIGNUP ================= +export const signupController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + + const { fullName, email, password, role, documents: businessInfo } = req.body; + + if (!fullName || !email || !password || !role) { + const err: HttpError = new Error("Missing required fields"); + err.statusCode = 400; + throw err; + } + + const result = await signupService({ + fullName, + email, + password, + role, + documents: businessInfo, + }); + + res.status(201).json({ + success: true, + message: "Signup successful. Please verify your email.", + data: result, + }); + } catch (error) { + next(error); + } +}; + + +// ================= LOGIN ================= export const loginController = async ( req: Request, res: Response, @@ -20,9 +68,275 @@ export const loginController = async ( const data = await loginService(email, password); + // Set refresh token as HttpOnly cookie + const cookieName = `refreshToken_${data.user.role}`; + res.cookie(cookieName, data.refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + }); + + // Send only access token and user info in response + res.status(200).json({ + success: true, + data: { + accessToken: data.accessToken, + user: data.user, + }, + }); + } catch (error) { + next(error); + } +}; + + + + + +export const refreshTokenController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { role } = req.body; + let refreshToken = role ? req.cookies[`refreshToken_${role}`] : null; + + if (!refreshToken) { + refreshToken = req.cookies.refreshToken_BRAND || req.cookies.refreshToken_INFLUENCER || req.cookies.refreshToken; + } + + if (!refreshToken) { + const err: HttpError = new Error("Refresh token missing"); + err.statusCode = 401; + throw err; + } + + const result = await refreshTokenService(refreshToken); + + // Set new refresh token as HttpOnly cookie + const cookieName = `refreshToken_${result.user.role}`; + res.cookie(cookieName, result.refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + }); + + // Send only access token in response + res.status(200).json({ + success: true, + data: { + accessToken: result.accessToken, + user: result.user, + + }, + }); + } catch (error) { + next(error); + } +}; + + +export const logoutController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const userId = req.user?.userId; + + if (!userId) { + return res.status(401).json({ + success: false, + message: "Unauthorized", + }); + } + + const result = await logoutService(userId); + + // Clear refresh token cookies + const cookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict" as const, + }; + if (req.user?.role) { + res.clearCookie(`refreshToken_${req.user.role}`, cookieOptions); + } + // Also clear generic for backward compatibility + res.clearCookie("refreshToken", cookieOptions); + + res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + next(error); + } +}; + +// ================= VERIFY SIGNUP OTP ================= +export const verifySignupOtpController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { email, otp } = req.body; + + if (!email || !otp) { + const err: HttpError = new Error("Email and OTP required"); + err.statusCode = 400; + throw err; + } + + await verifySignupOtpService(email, otp); + + res.status(200).json({ + success: true, + message: "Email verified successfully", + }); + } catch (error) { + next(error); + } +}; + +// ================= RESEND SIGNUP OTP ================= +export const resendSignupOtpController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { email } = req.body; + + if (!email) { + const err: HttpError = new Error("Email is required"); + err.statusCode = 400; + throw err; + } + + await resendSignupOtpService(email); + + res.status(200).json({ + success: true, + message: "OTP resent successfully", + }); + } catch (error) { + next(error); + } +}; + + +// ================= FORGOT PASSWORD ================= +export const forgotPasswordController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { email } = req.body; + + if (!email) { + const err: HttpError = new Error("Email is required"); + err.statusCode = 400; + throw err; + } + + await forgotPasswordService(email); + + res.status(200).json({ + success: true, + message: "If account exists, OTP sent", + + }); + + } catch (error) { + next(error); + } +}; + +// ================= VERIFY OTP ================= +export const verifyResetOtpController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { email, otp } = req.body; + + if (!email || !otp) { + const err: HttpError = new Error("Email and OTP required"); + err.statusCode = 400; + throw err; + } + + const resetSessionToken = await verifyOtpService(email, otp); + + res.status(200).json({ + success: true, + message: "OTP verified", + data: { resetSessionToken }, + }); + } catch (error) { + next(error); + } +}; + +// ================= RESET PASSWORD ================= +export const resetPasswordController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { email, newPassword, resetSessionToken } = req.body; + + if (!email || !newPassword || !resetSessionToken) { + const err: HttpError = new Error("Missing required fields"); + err.statusCode = 400; + throw err; + } + + await resetPasswordService(email, newPassword, resetSessionToken); + + res.status(200).json({ + success: true, + message: "Password reset successful", + }); + } catch (error) { + next(error); + } +}; + + + + + + + +// ================= PENDING PROFILE ================= +export const pendingProfileController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { email, profileData } = req.body; + + if (!email || !profileData) { + const err: HttpError = new Error("Email and profile data required"); + err.statusCode = 400; + throw err; + } + + const result = await pendingProfileService(email, profileData); + res.status(200).json({ success: true, - data, + data: result, }); } catch (error) { next(error); diff --git a/apps/core-api/src/controllers/availability.controller.ts b/apps/core-api/src/controllers/availability.controller.ts new file mode 100644 index 0000000..e4db1e0 --- /dev/null +++ b/apps/core-api/src/controllers/availability.controller.ts @@ -0,0 +1,111 @@ +// import type { NextFunction, Request, Response } from "express"; + +// import { +// getAvailabilityService, +// setAvailabilityService +// } from "../services/availability.service.js"; + +// export const setAvailabilityController = async ( +// req: Request, +// res: Response, +// next: NextFunction +// ) => { +// try { +// const { userId, role } = req.user!; + +// const availability = await setAvailabilityService( +// userId, +// role, +// req.body +// ); + +// res.status(200).json({ +// success: true, +// data: availability +// }); +// } catch (error) { +// next(error); +// } +// }; + +// export const getAvailabilityController = async ( +// req: Request, +// res: Response, +// next: NextFunction +// ) => { +// try { +// const influencerProfileId = req.params.influencerProfileId as string; + +// const availability = await getAvailabilityService( +// influencerProfileId +// ); + +// res.status(200).json({ +// success: true, +// data: availability +// }); +// } catch (error) { +// next(error); +// } +// }; + +import type{ Response } from "express"; + +import { AvailabilityService } from "../services/availability.service.js"; +import type { AuthRequest } from "../middlewares/auth.middleware.js"; + +const service = new AvailabilityService(); + +export class AvailabilityController { + async addUnavailable(req: AuthRequest, res: Response) { + if (!req.user) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const influencerId = req.user.userId; + const { date, reason } = req.body; + + const data = await service.addUnavailableDate( + influencerId, + new Date(date), + reason + ); + + res.json({ + success: true, + message: "Date marked as unavailable", + data, + }); + } + + async removeUnavailable(req: AuthRequest, res: Response) { + if (!req.user) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const influencerId = req.user.userId; + const { date } = req.body; + + const data = await service.removeUnavailableDate( + influencerId, + new Date(date) + ); + + res.json({ + success: true, + message: "Unavailable date removed", + data, + }); + } + + async checkToday(req: AuthRequest, res: Response) { + const influencerId = req.params.influencerId as string; + + const isAvailable = await service.isAvailableToday(influencerId); + + res.json({ + success: true, + data: { isAvailable }, + }); + } +} \ No newline at end of file diff --git a/apps/core-api/src/controllers/brand.controller.ts b/apps/core-api/src/controllers/brand.controller.ts new file mode 100644 index 0000000..e821e49 --- /dev/null +++ b/apps/core-api/src/controllers/brand.controller.ts @@ -0,0 +1,31 @@ +import type { Request, Response, NextFunction } from "express"; + +import { getPublicBrandProfileService } from "../services/brand.service.js"; + +export const getPublicBrandProfileController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + + const { brandId } = req.params; + + if (!brandId || Array.isArray(brandId)) { + return res.status(400).json({ + success: false, + message: "Invalid brandId" + }); + } + + const brand = await getPublicBrandProfileService(brandId); + + res.status(200).json({ + success: true, + data: brand + }); + + } catch (error) { + next(error); + } +}; \ No newline at end of file diff --git a/apps/core-api/src/controllers/chat.controller.ts b/apps/core-api/src/controllers/chat.controller.ts new file mode 100644 index 0000000..688a101 --- /dev/null +++ b/apps/core-api/src/controllers/chat.controller.ts @@ -0,0 +1,183 @@ +import type { Request, Response } from "express"; +import mongoose from "mongoose"; + +import { getConversations, getMessages, sendMessage, respondToProposal, submitDeliverable, respondToDeliverable } from "../services/chat.service.js"; +import { MessageModel } from "../models/chat.model.js"; +import { notificationQueue } from "../queue/notification.queue.js"; + +// Get messages for a gig request +export const getChatMessagesController = async ( + req: Request, + res: Response +) => { + try { + const gigRequestId = req.params.gigRequestId as string; + + const messages = await getMessages(gigRequestId); + + res.json({ messages }); + } catch (err) { + console.error("getChatMessages error:", err); + res.status(500).json({ message: "Internal server error" }); + } +}; + +// Send message (REST fallback) +export const sendMessageController = async (req: Request, res: Response) => { + try { + const senderId = req.user!.userId; + const { gigRequestId, content, messageType, proposalData } = req.body; + + const message = await sendMessage(senderId, gigRequestId, content, messageType, proposalData); + + const receiverId = message.receiverId.toString(); + const conversationId = message.gigRequestId.toString(); + const messageId = message._id.toString(); + + // Add to notificationQueue + await notificationQueue.add( + "NEW_MESSAGE", + { + userId: receiverId, + type: "NEW_MESSAGE", + title: "New Message", + message: "You have a new message", + metadata: { + conversationId, + messageId, + }, + }, + { + jobId: `notify:msg:${messageId}`, + } + ); + + res.json({ success: true, message }); + + } catch (err: unknown) { + console.error("sendMessage error:", err); + if ((err as Error).message === "Chat is only available for accepted gig requests") { + return res.status(403).json({ message: (err as Error).message }); + } + res.status(500).json({ message: "Internal server error" }); + } +}; + +// Get all conversations (sidebar) +export const getConversationsController = async ( + req: Request, + res: Response +) => { + try { + const userId = req.user!.userId; + const role = (req.query.role as string) || undefined; + + const data = await getConversations(userId, role); + + res.json({ conversations: data }); + } catch (err) { + console.error("getConversations error:", err); + res.status(500).json({ message: "Internal server error" }); + } +}; + +// Mark messages as READ +export const markAsReadController = async ( + req: Request, + res: Response +) => { + try { + const userId = req.user!.userId; + const gigRequestId = req.params.gigRequestId as string; + + await MessageModel.updateMany( + { + gigRequestId: new mongoose.Types.ObjectId(gigRequestId), + receiverId: userId, + status: { $ne: "READ" }, + }, + { + status: "READ", + } + ); + + res.json({ success: true }); + } catch (err) { + console.error("markAsRead error:", err); + res.status(500).json({ message: "Internal server error" }); + } +}; + +export const respondToProposalController = async (req: Request, res: Response) => { + try { + const userId = req.user!.userId; + const messageId = req.params.messageId as string; + const { status } = req.body; // "ACCEPTED" | "REJECTED" + + if (!["ACCEPTED", "REJECTED"].includes(status)) { + return res.status(400).json({ message: "Invalid status" }); + } + + const message = await respondToProposal(messageId, userId, status); + + res.json({ success: true, message }); + } catch (err: unknown) { + console.error("respondToProposal error:", err); + res.status(400).json({ message: (err as Error).message }); + } +}; + +export const getAgreedProposalsController = async (req: Request, res: Response) => { + try { + const userId = req.user!.userId; + + // Find messages where type=PROPOSAL, status=ACCEPTED and user is sender or receiver + const proposals = await MessageModel.find({ + messageType: "PROPOSAL", + "proposalData.status": "ACCEPTED", + $or: [{ senderId: userId }, { receiverId: userId }] + }).populate("gigRequestId"); + + res.json({ proposals }); + } catch (err) { + console.error("getAgreedProposals error:", err); + res.status(500).json({ message: "Internal server error" }); + } +}; + +export const submitDeliverableController = async (req: Request, res: Response) => { + try { + const senderId = req.user!.userId; + const { gigRequestId, deliverableData } = req.body; + + if (!deliverableData || !deliverableData.url) { + return res.status(400).json({ message: "Deliverable URL is required" }); + } + + const message = await submitDeliverable(senderId, gigRequestId, deliverableData); + + res.json({ success: true, message }); + } catch (err: unknown) { + console.error("submitDeliverable error:", err); + res.status(400).json({ message: (err as Error).message }); + } +}; + +export const respondToDeliverableController = async (req: Request, res: Response) => { + try { + const userId = req.user!.userId; + const messageId = req.params.messageId as string; + const { status, rejectionNote } = req.body; + + if (!["ACCEPTED", "REJECTED"].includes(status)) { + return res.status(400).json({ message: "Invalid status" }); + } + + const message = await respondToDeliverable(messageId, userId, status, rejectionNote); + + res.json({ success: true, message }); + } catch (err: unknown) { + console.error("respondToDeliverable error:", err); + res.status(400).json({ message: (err as Error).message }); + } +}; \ No newline at end of file diff --git a/apps/core-api/src/controllers/collaboration.controllers.ts b/apps/core-api/src/controllers/collaboration.controllers.ts new file mode 100644 index 0000000..e1ac44f --- /dev/null +++ b/apps/core-api/src/controllers/collaboration.controllers.ts @@ -0,0 +1,55 @@ +import type { Request, Response, NextFunction } from "express"; + +import { + inviteCollaboratorsService, + respondToCollaborationService +} from "../services/collaboration.service.js"; + +export const inviteCollaboratorsController = async ( + req: Request<{ id: string }>, + res: Response, + next: NextFunction +) => { + try { + const { userId } = req.user!; + const { id } = req.params; + + const collaborations = await inviteCollaboratorsService( + id, + userId, + req.body.collaboratorIds + ); + + res.status(201).json({ + success: true, + data: collaborations + }); + } catch (err) { + next(err); + } +}; + +export const respondToCollaborationController = async ( + req: Request<{ id: string }>, + res: Response, + next: NextFunction +) => { + try { + const { userId } = req.user!; + const { id } = req.params; + const { action } = req.body; + + const result = await respondToCollaborationService( + id, + userId, + action + ); + + res.status(200).json({ + success: true, + data: result + }); + } catch (err) { + next(err); + } +}; \ No newline at end of file diff --git a/apps/core-api/src/controllers/connection.controller.ts b/apps/core-api/src/controllers/connection.controller.ts new file mode 100644 index 0000000..d9eb012 --- /dev/null +++ b/apps/core-api/src/controllers/connection.controller.ts @@ -0,0 +1,105 @@ +import type { Response } from "express"; + +import { ConnectionService } from "../services/connection.service.js"; +import type { AuthRequest } from "../middlewares/auth.middleware.js"; + +const service = new ConnectionService(); + +export class ConnectionController { + async sendRequest(req: AuthRequest, res: Response) { + if (!req.user) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const brandId = req.user.userId; + const { influencerId, gigId, note } = req.body; + + try { + const data = await service.sendRequest( + brandId, + influencerId as string, + gigId as string, + note as string + ); + + res.json({ + success: true, + message: "Connection request sent", + data, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + if (error.message === "Already requested this gig.") { + return res.status(400).json({ success: false, message: error.message }); + } + return res.status(500).json({ success: false, message: "Internal server error" }); + } + } + + async accept(req: AuthRequest, res: Response) { + const id = req.params.id as string; + + const data = await service.acceptRequest(id); + + res.json({ + success: true, + message: "Connection accepted", + data, + }); + } + + async reject(req: AuthRequest, res: Response) { + const id = req.params.id as string; + + const data = await service.rejectRequest(id); + + res.json({ + success: true, + message: "Connection rejected", + data, + }); + } + + async myConnections(req: AuthRequest, res: Response) { + if (!req.user) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const userId = req.user.userId; + const role = (req.query.role as string) || undefined; + + const data = await service.getMyConnections(userId, role); + + res.json({ + success: true, + data, + }); + } + + async getConnectionWithReceiver(req: AuthRequest, res: Response) { + if (!req.user) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const currentUserId = req.user.userId; + const receiverId = req.params.receiverId as string; + const gigId = req.query.gigId as string; + + const connection = await service.getConnectionBetween(currentUserId, receiverId, gigId); + + res.json({ + success: true, + connection, + }); + } + + async getById(req: AuthRequest, res: Response) { + const id = req.params.id as string; + const connection = await service.getConnectionById(id); + + res.json({ + success: true, + connection, + }); + } +} \ No newline at end of file diff --git a/apps/core-api/src/controllers/gig-request.controller.ts b/apps/core-api/src/controllers/gig-request.controller.ts new file mode 100644 index 0000000..a2275b5 --- /dev/null +++ b/apps/core-api/src/controllers/gig-request.controller.ts @@ -0,0 +1,86 @@ +import type { Response } from "express"; + +import { GigRequestService } from "../services/gig-request.service.js"; +import type { AuthRequest } from "../middlewares/auth.middleware.js"; + +const service = new GigRequestService(); + +export class GigRequestController { + // Brand sends a gig request + async sendRequest(req: AuthRequest, res: Response) { + if (!req.user) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const brandId = req.user.userId; + const { influencerId, gigId, note } = req.body; + + if (!gigId) { + return res.status(400).json({ success: false, message: "gigId is required" }); + } + + try { + const data = await service.sendRequest( + brandId, + influencerId as string, + gigId as string, + note as string | undefined + ); + + return res.status(201).json({ + success: true, + message: "Gig request sent", + data, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + if (error.message === "Already requested this gig.") { + return res.status(400).json({ success: false, message: error.message }); + } + return res.status(500).json({ success: false, message: "Internal server error" }); + } + } + + // Influencer accepts a request + async accept(req: AuthRequest, res: Response) { + try { + const id = req.params.id as string; + const data = await service.acceptRequest(id); + return res.json({ success: true, message: "Gig request accepted", data }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + return res.status(400).json({ success: false, message: error.message }); + } + } + + // Influencer rejects a request + async reject(req: AuthRequest, res: Response) { + try { + const id = req.params.id as string; + const data = await service.rejectRequest(id); + return res.json({ success: true, message: "Gig request rejected", data }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + return res.status(400).json({ success: false, message: error.message }); + } + } + + // Get all requests for the calling user + async myRequests(req: AuthRequest, res: Response) { + if (!req.user) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const userId = req.user.userId; + const data = await service.getMyRequests(userId); + + return res.json({ success: true, data }); + } + + // Get a single request by ID + async getById(req: AuthRequest, res: Response) { + const id = req.params.id as string; + const gigRequest = await service.getById(id); + return res.json({ success: true, gigRequest }); + } +} diff --git a/apps/core-api/src/controllers/gig.controller.ts b/apps/core-api/src/controllers/gig.controller.ts new file mode 100644 index 0000000..0dc4515 --- /dev/null +++ b/apps/core-api/src/controllers/gig.controller.ts @@ -0,0 +1,345 @@ +import type { NextFunction, Request, Response } from "express"; + +import { + createGigService, + deleteGigService, + editGigService, + getGigDetailsService, + getMyGigsService, + listGigsService, + publishGigService, + updateGigDeliverablesService, + updateGigPricingService +} from "../services/gig.service.js"; +import type { AuthRequest } from "../middlewares/auth.middleware.js"; +import type { GigDeliverable } from "../types/gig.type.js"; +import { getChannel } from "../queue/rabbit.js"; + + + + +// ================= CREATE GIG ================= + +export const GIG_CREATED_EVENT = "gig.created"; + +/* ================= CREATE GIG ================= */ + + +export const createGigController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + + const { userId, role } = req.user!; + + const result = await createGigService( + userId, + role, + req.body + ); + + + + getChannel().sendToQueue( + GIG_CREATED_EVENT, + Buffer.from(JSON.stringify({ + gigId: result.gig._id.toString(), + title: result.gig.title, + category: result.gig.category, + pricing: result.gig.pricing.basePrice, + influencerId: result.gig.primaryInfluencerId.toString(), + createdAt: result.gig.createdAt, + })), + { persistent: true } + ); + + + + return res.status(201).json({ + success: true, + message: + result.collaborators.length > 0 + ? "Gig created as draft. Waiting for collaborator approval." + : "Gig created and published successfully.", + data: result.gig + }); + } catch (error) { + next(error); + } +}; + +/* ================= LIST GIGS ================= */ + +export const listGigsController = async ( + req: Request, + res: Response +) => { + try { + const page = Number(req.query.page) || 1; + const limit = Number(req.query.limit) || 10; + + const result = await listGigsService(req.query, page, limit); + + return res.status(200).json(result); + } catch (error: unknown) { + console.error("Error listing gigs:", error); + + return res.status(500).json({ + message: "Internal server error" + }); + } +}; + +/* ================= GET GIG DETAILS ================= */ + +interface HttpError extends Error { + statusCode?: number; +} + +export const getGigDetailsController = async ( + req: Request<{ id: string }>, + res: Response +) => { + try { + const { id } = req.params; + + const gig = await getGigDetailsService(id); + + return res.status(200).json({ + success: true, + data: gig + }); + } catch (error: unknown) { + console.error("Error fetching gig details:", error); + + const err = error as HttpError; + + return res.status(err.statusCode || 500).json({ + message: err.message || "Internal server error" + }); + } +}; + + +// ================= EDIT GIG ================= +export const editGigController = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + + const { id } = req.params; + + // 🔥 Type-safe ID guard + if (typeof id !== "string") { + return res.status(400).json({ + message: "Invalid Gig ID" + }); + } + + if (!req.user) { + return res.status(401).json({ + message: "Unauthorized" + }); + } + + const updatedGig = await editGigService( + id, + req.user, + req.body + ); + + return res.status(200).json({ + success: true, + data: updatedGig + }); + + } catch (error) { + next(error); + } +}; + + + + +//* ================= DELETE GIG ================= + +export const deleteGigController = async ( + req: AuthRequest, + res: Response +) => { + try { + const { id } = req.params; + + // 1️⃣ Validate ID exists and is string + if (!id || Array.isArray(id)) { + return res.status(400).json({ + message: "Invalid Gig ID" + }); + } + + // 2️⃣ Ensure authenticated user exists + if (!req.user) { + return res.status(401).json({ + message: "Unauthorized" + }); + } + + await deleteGigService(id, req.user); + + return res.status(200).json({ + message: "Gig deleted successfully" + }); + + } catch (error: unknown) { + console.error("Error deleting gig:", error); + + if (error instanceof Error) { + const statusCode = + (error as { statusCode?: number }).statusCode ?? 500; + + return res.status(statusCode).json({ + message: error.message + }); + } + + return res.status(500).json({ + message: "Internal server error" + }); + } +}; + +/* ================= PUBLISH GIG ================= */ + +export const publishGigController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + + const { userId } = req.user!; + + const id = req.params.id as string; + + if (!id) { + return res.status(400).json({ + success: false, + message: "Gig id is required" + }); + } + + const gig = await publishGigService(id, userId); + + res.status(200).json({ + success: true, + message: "Gig published successfully", + data: gig + }); + } catch (err) { + next(err); + } +}; + +/* ================= UPDATE DELIVERABLES ================= */ + +export const updateGigDeliverablesController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + + const { userId } = req.user!; + const id = req.params.id as string; + + if (!id) { + return res.status(400).json({ + success: false, + message: "Gig id is required" + }); + } + + const deliverables = req.body as GigDeliverable[]; + + const gig = await updateGigDeliverablesService( + id, + userId, + deliverables + ); + + res.status(200).json({ + success: true, + message: "Deliverables updated successfully", + data: gig + }); + } catch (error) { + next(error); + } +}; + +/* ================= UPDATE PRICING ================= */ + +export const updateGigPricingController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { userId } = req.user!; + const id = req.params.id as string; + + if (!id) { + return res.status(400).json({ + success: false, + message: "Gig id is required" + }); + } + + const gig = await updateGigPricingService( + id, + userId, + req.body + ); + + res.status(200).json({ + success: true, + message: "Pricing updated successfully", + data: gig + }); + } catch (error) { + next(error); + } +}; + +/* ================= GET GIGS ================= */ + +export const getGigController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { id } = req.params; + + if (typeof id !== "string") { + return res.status(400).json({ + message: "Invalid Gig ID" + }); + } + + const gig = await getMyGigsService(id); + + res.status(200).json({ + success: true, + data: gig + }); + + } catch (error) { + next(error); + } +}; + diff --git a/apps/core-api/src/controllers/media.controller.ts b/apps/core-api/src/controllers/media.controller.ts new file mode 100644 index 0000000..b99d98f --- /dev/null +++ b/apps/core-api/src/controllers/media.controller.ts @@ -0,0 +1,28 @@ +import type { Request, Response, NextFunction } from "express"; + +import { generateUploadUrlService } from "../services/media.service.js"; + +export const generateUploadUrlController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + + const { fileName, fileType, folder } = req.body; + + const result = await generateUploadUrlService( + folder, + fileName, + fileType + ); + + res.status(200).json({ + success: true, + data: result + }); + + } catch (error) { + next(error); + } +}; diff --git a/apps/core-api/src/controllers/notification.controller.ts b/apps/core-api/src/controllers/notification.controller.ts new file mode 100644 index 0000000..aa830a5 --- /dev/null +++ b/apps/core-api/src/controllers/notification.controller.ts @@ -0,0 +1,127 @@ +import type { Response } from "express"; + +import { NotificationService } from "../services/notification.service.js"; +import type { AuthRequest } from "../middlewares/auth.middleware.js"; + +const service = new NotificationService(); + +export class NotificationController { + async saveSubscription(req: AuthRequest, res: Response) { + if (!req.user) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const userId = req.user.userId; + const { subscription } = req.body; + + if (!subscription || !subscription.endpoint) { + return res.status(400).json({ success: false, message: "Invalid subscription" }); + } + + try { + const data = await service.saveSubscription(userId, subscription); + res.status(201).json({ success: true, message: "Subscription saved", data }); + } catch (error: unknown) { + console.error("[NotificationController.saveSubscription]", error); + + res.status(500).json({ + success: false, + message: "Internal server error", + }); +} + } + + async removeSubscription(req: AuthRequest, res: Response) { + const { endpoint } = req.body; + + if (!endpoint) { + return res.status(400).json({ success: false, message: "Endpoint required" }); + } + + try { + await service.removeSubscription(endpoint); + res.json({ success: true, message: "Subscription removed" }); + } catch (error: unknown) { + console.error("[NotificationController.removeSubscription]", error); + + res.status(500).json({ + success: false, + message: "Internal server error", + }); +} + } + + async getMyNotifications(req: AuthRequest, res: Response) { + if (!req.user) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const userId = req.user.userId; + const limit = parseInt(req.query.limit as string) || 20; + + try { + const notifications = await service.getUserNotifications(userId, limit); + const unreadCount = await service.getUnreadCount(userId); + + res.json({ + success: true, + data: { + notifications, + unreadCount, + }, + }); + } catch (error: unknown) { + console.error("[NotificationController.getMyNotifications]", error); + + res.status(500).json({ + success: false, + message: "Internal server error", + }); +} + } + + async markAsRead(req: AuthRequest, res: Response) { + if (!req.user) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const userId = req.user.userId; + const notificationId = req.params.id as string; + + + try { + const updated = await service.markAsRead(notificationId, userId); + if (!updated) { + return res.status(404).json({ success: false, message: "Notification not found" }); + } + res.json({ success: true, message: "Marked as read", data: updated }); + } catch (error: unknown) { + console.error("[NotificationController.markAsRead]", error); + + res.status(500).json({ + success: false, + message: "Internal server error", + }); +} + } + + async markAllAsRead(req: AuthRequest, res: Response) { + if (!req.user) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const userId = req.user.userId; + + try { + await service.markAllAsRead(userId); + res.json({ success: true, message: "All marked as read" }); + } catch (error: unknown) { + console.error("[NotificationController.markAllAsRead]", error); + + res.status(500).json({ + success: false, + message: "Internal server error", + }); + } + } +} diff --git a/apps/core-api/src/controllers/order.controller.ts b/apps/core-api/src/controllers/order.controller.ts new file mode 100644 index 0000000..13b0e25 --- /dev/null +++ b/apps/core-api/src/controllers/order.controller.ts @@ -0,0 +1,363 @@ +import type { Response } from "express"; +import { Types } from "mongoose"; + +import type { AuthRequest } from "../middlewares/auth.middleware.js"; +import { createOrderService, type CreateOrderInput } from "../services/order.service.js"; +import { OrderModel } from "../models/order.model.js"; +import { InfluencerProfile } from "../models/influencer.model.js"; +import { stripe } from "../lib/stripe.js"; +import { publishEvent } from "../queue/publisher.js"; +import { PlatformRevenueModel } from "../models/platform-revenue.model.js"; +import { MessageModel } from "../models/chat.model.js"; +import { GigRequestModel } from "../models/gig-request.model.js"; +import { BrandProfile } from "../models/brand.model.js"; + + +// ✅ CREATE ORDER (already exists) +export const createOrder = async (req: AuthRequest, res: Response) => { + const result = await createOrderService(req.body); + res.json(result); +}; + + +// 🔥 NEW: RELEASE PAYMENT +export const releasePayment = async (req: AuthRequest, res: Response) => { + const { id } = req.params; + + + const order = await OrderModel.findById(id); + + if (!order) { + return res.status(404).json({ message: "OrderModel not found" }); + } + + if (order.escrowStatus !== "HOLD") { + return res.status(400).json({ message: "Payment not in escrow" }); + } + + // 🔥 RELEASE MONEY + order.status = "COMPLETED"; + order.escrowStatus = "RELEASED"; + + await order.save(); + await publishEvent("payment.released", { + orderId: order._id.toString(), + influencerId: order.influencerId, + }); + res.json({ + message: "Payment released to influencer ✅", + }); +}; + + + + +/// ================= MARK WORK AS SUBMITTED (INFLUENCER) ================= + + + +export const markCompleted = async (req: AuthRequest, res: Response) => { + const { id } = req.params; + const { deliverableUrl } = req.body; + + const order = await OrderModel.findById(id); + + if (!order) { + return res.status(404).json({ message: "OrderModel not found" }); + } + + order.workStatus = "SUBMITTED"; + order.rejectionNote = ""; // Clear previous rejection note on resubmission + if (deliverableUrl) { + order.deliverableUrl = deliverableUrl; + } + + await order.save(); + await publishEvent("order.submitted", { + orderId: order._id.toString(), + buyerId: order.buyerId, + }); + + res.json({ message: "Work submitted ✅" }); +}; + +// ================= REJECT WORK (BRAND) ================= + +export const rejectWork = async (req: AuthRequest, res: Response) => { + const { id } = req.params; + const { note } = req.body; + + const order = await OrderModel.findById(id); + + if (!order) { + return res.status(404).json({ message: "Order not found" }); + } + + if (order.workStatus !== "SUBMITTED") { + return res.status(400).json({ message: "Can only reject submitted work" }); + } + + order.workStatus = "REJECTED"; + order.rejectionNote = note; + + await order.save(); + + await publishEvent("order.rejected", { + orderId: order._id.toString(), + influencerId: order.influencerId, + reason: note + }); + + res.json({ message: "Work rejected with feedback ✅" }); +}; + + +// ================= APPROVE WORK & RELEASE PAYMENT (BRAND) ================= + +export const approveWork = async (req: AuthRequest, res: Response) => { + const { id } = req.params; + + const order = await OrderModel.findById(id); + + if (!order) { + return res.status(404).json({ message: "OrderModel not found" }); + } + + if (order.workStatus !== "SUBMITTED") { + return res.status(400).json({ message: "Work not submitted yet" }); + } + + // 🔥 UNLOCK FUNDS FOR MANUAL WITHDRAWAL + order.workStatus = "APPROVED"; + order.status = "COMPLETED"; + order.escrowStatus = "RELEASED"; + + // Financial payout status + order.payoutStatus = "AVAILABLE"; + order.availableAt = new Date(); + + await order.save(); + + await publishEvent("order.completed", { + orderId: order._id.toString(), + influencerId: order.influencerId, + }); + + res.json({ + message: "Work approved. Funds are now available for withdrawal in your dashboard. ✅", + availableAt: order.availableAt + }); +}; + + +// ================= CANCEL ORDER & REFUND (BRAND/ADMIN) ================= +export const cancelOrder = async (req: AuthRequest, res: Response) => { + const { id } = req.params; + + const order = await OrderModel.findById(id); + + if (!order) { + return res.status(404).json({ message: "Order not found" }); + } + + // 🔥 RESTRICTION: Only allow refund if status is IN_ESCROW or it is completed but NOT yet requested for withdrawal + const canRefund = (order.status === "IN_ESCROW" && order.escrowStatus === "HOLD") || + (order.status === "COMPLETED" && order.payoutStatus === "AVAILABLE"); + + if (!canRefund) { + return res.status(400).json({ + message: "Order cannot be refunded at this stage. (Already processing withdrawal or cancelled)" + }); + } + + try { + // 🔥 STRIPE REFUND + await stripe.refunds.create({ + payment_intent: order.stripePaymentIntentId as string, + }); + + order.status = "CANCELLED"; + order.escrowStatus = "RELEASED"; + order.payoutStatus = "HOLD"; // Reset payout status + + await order.save(); + + await publishEvent("order.cancelled", { + orderId: order._id.toString(), + buyerId: order.buyerId, + }); + + res.json({ message: "Order cancelled and refund initiated ✅" }); + } catch (err: unknown) { + const error = err as Error; + console.error("Stripe Refund Error:", error); + res.status(500).json({ message: "Failed to initiate refund via Stripe." }); + } +}; + +// ================= GET ORDER DETAILS ================= +export const getOrderDetails = async (req: AuthRequest, res: Response) => { + const { id } = req.params; + + const order = await OrderModel.findById(id); + if (!order) return res.status(404).json({ message: "Order not found" }); + res.json(order); +}; + +// ================= GET TRANSACTION HISTORY ================= +export const getHistory = async (req: AuthRequest, res: Response) => { + if (!req.user) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const { userId } = req.user; + + try { + // 1. Get existing orders + const orders = await OrderModel.find({ + $or: [ + { buyerId: new Types.ObjectId(userId) }, + { influencerId: new Types.ObjectId(userId) } + ] + }) + .populate("gigId") + .sort({ createdAt: -1 }) + .lean(); + + // 2. Get accepted GigRequests that DON'T have an order yet + const existingOrderConnectionIds = orders + .filter(o => o.connectionId) + .map(o => o.connectionId.toString()); + + const pendingRequests = await GigRequestModel.find({ + $or: [ + { brandId: new Types.ObjectId(userId) }, + { influencerId: new Types.ObjectId(userId) } + ], + status: "accepted", + _id: { $nin: existingOrderConnectionIds.map(id => new Types.ObjectId(id)) } + }) + .populate("gigId") + .sort({ createdAt: -1 }) + .lean(); + + // 3. Map GigRequests to a mock "Order" structure for the frontend + const virtualOrders = pendingRequests.map((req: Record) => ({ + _id: `virtual-${req._id as string}`, + connectionId: req._id as string, + gigId: req.gigId as { title: string; pricing?: { basePrice: number } }, + buyerId: req.brandId as string, + influencerId: req.influencerId as string, + amount: (req.gigId as { pricing?: { basePrice: number } })?.pricing?.basePrice || 0, + status: "PENDING", + workStatus: "NOT_STARTED", + createdAt: req.createdAt as string, + isVirtual: true + })); + + // 4. Combine and populate influencer profiles + const allEntries = [...orders, ...virtualOrders].sort((a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + + const influencerIds = [...new Set(allEntries.map(o => (o as { influencerId: Types.ObjectId | string }).influencerId.toString()))]; + const profiles = await InfluencerProfile.find({ userId: { $in: influencerIds } }).lean(); + + const buyerIds = [...new Set(allEntries.map(o => (o as { buyerId: Types.ObjectId | string }).buyerId?.toString()).filter(id => !!id))]; + const brandProfiles = await BrandProfile.find({ userId: { $in: buyerIds } }).lean(); + + const populatedEntries = allEntries.map(entry => { + const e = entry as { influencerId: Types.ObjectId | string, buyerId?: Types.ObjectId | string, brandId?: Types.ObjectId | string }; + const profile = (profiles as unknown as { userId: { toString: () => string } }[]).find(p => p.userId.toString() === e.influencerId.toString()); + const buyerId = e.buyerId?.toString() || e.brandId?.toString(); + const bProfile = (brandProfiles as unknown as { userId: { toString: () => string } }[]).find(bp => bp.userId.toString() === buyerId); + return { ...entry, influencerProfile: profile, brandProfile: bProfile }; + }); + + console.log(`Fetched ${orders.length} orders and ${virtualOrders.length} virtual orders for user ${userId}`); + res.json(populatedEntries); + } catch (error) { + console.error("Error in getHistory:", error); + res.status(500).json({ message: "Failed to fetch booking history" }); + } +}; + +// ================= GET ORDER BY STRIPE SESSION ================= +export const getOrderBySession = async (req: AuthRequest, res: Response) => { + const { sessionId } = req.params; + + try { + const session = await stripe.checkout.sessions.retrieve(sessionId as string); + const { orderId: metadataOrderId, gigRequestId, gigId, buyerId, influencerId, amount: amountStr, dueDate } = session.metadata || {}; + + let order = null; + + if (metadataOrderId) { + order = await OrderModel.findById(metadataOrderId); + } else if (gigRequestId) { + order = await OrderModel.findOne({ connectionId: gigRequestId }); + } + + // 🔥 AUTO-HEAL: Create order if it doesn't exist but payment is successful + if (!order && session.payment_status === "paid" && gigRequestId) { + const amount = parseFloat(amountStr || "0"); + const orderData: CreateOrderInput = { + gigId: gigId as string, + buyerId: buyerId as string, + influencerId: influencerId as string, + connectionId: gigRequestId as string, + amount, + }; + if (dueDate) { + orderData.dueDate = dueDate as string; + } + const result = await createOrderService(orderData); + order = await OrderModel.findById(result.orderId); + console.log("Auto-created Order on Success route via session retrieval"); + } + + if (!order) return res.status(404).json({ message: "Order not found" }); + + // 🔥 AUTO-HEAL status: If Stripe webhook was bypassed + if (session.payment_status === "paid" && order.status === "PENDING") { + const platformFee = order.amount * 0.05; + const influencerAmount = order.amount - platformFee; + + order.status = "IN_ESCROW"; + order.escrowStatus = "HOLD"; + order.stripePaymentIntentId = session.payment_intent as string; + order.platformFee = platformFee; + order.influencerAmount = influencerAmount; + await order.save(); + + const profile = await InfluencerProfile.findOne({ userId: order.influencerId }); + if (profile) { + profile.balance = (profile.balance || 0) + influencerAmount; + profile.totalEarnings = (profile.totalEarnings || 0) + influencerAmount; + await profile.save(); + } + + let platform = await PlatformRevenueModel.findOne(); + if (!platform) platform = new PlatformRevenueModel(); + platform.totalRevenue = (platform.totalRevenue || 0) + platformFee; + platform.availableBalance = (platform.availableBalance || 0) + platformFee; + await platform.save(); + + // 🔥 AUTOMATED SYSTEM CHAT MESSAGE (Fallback delivery) + await MessageModel.create({ + gigRequestId: order.connectionId, + senderId: order.buyerId, + receiverId: order.influencerId, + content: `₹${order.amount.toLocaleString()} has been safely secured in platform Escrow!`, + messageType: "SYSTEM", + status: "SENT", + }); + console.log("Auto-healed Order Ledger & injected System Message on Success route"); + } + + res.json(order); + } catch (err) { + console.error("getOrderBySession error:", err); + res.status(500).json({ message: "Failed to retrieve session" }); + } +}; \ No newline at end of file diff --git a/apps/core-api/src/controllers/payment.controller.ts b/apps/core-api/src/controllers/payment.controller.ts new file mode 100644 index 0000000..ccfe666 --- /dev/null +++ b/apps/core-api/src/controllers/payment.controller.ts @@ -0,0 +1,235 @@ +import type { Request, Response, NextFunction } from "express"; +import type Stripe from "stripe"; + +import type { AuthRequest } from "../middlewares/auth.middleware.js"; +import { InfluencerProfile } from "../models/influencer.model.js"; +import { stripe } from "../lib/stripe.js"; +import { OrderModel } from "../models/order.model.js"; +import { createCheckoutSession } from "../services/payment.service.js"; +import { publishEvent } from "../queue/publisher.js"; +import { PlatformRevenueModel } from "../models/platform-revenue.model.js"; +import { MessageModel } from "../models/chat.model.js"; +import { GigRequestModel } from "../models/gig-request.model.js"; +import { GigModel } from "../models/gig.model.js"; +import { createOrderService } from "../services/order.service.js"; +import type { CreateOrderInput } from "../services/order.service.js"; + + +export const createCheckout = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { gigRequestId } = req.body; + let { amount } = req.body; + + if (!gigRequestId) { + return res.status(400).json({ message: "Gig Request ID is required." }); + } + + const gigRequest = await GigRequestModel.findById(gigRequestId).populate("gigId"); + if (!gigRequest) { + return res.status(404).json({ message: `Gig Request not found with ID: ${gigRequestId}` }); + } + + if (gigRequest.status !== "accepted") { + return res.status(400).json({ message: "Gig Request must be accepted before payment." }); + } + + const gig = await GigModel.findById(gigRequest.gigId); + if (!gig) { + return res.status(404).json({ message: "Gig information not found." }); + } + + // Amount can be overridden by frontend (negotiated price), otherwise use base price + if (!amount) { + amount = gig.pricing.basePrice; + } + + const influencerProfile = await InfluencerProfile.findOne({ userId: gigRequest.influencerId }); + if (!influencerProfile?.stripeAccountId && process.env.STRIPE_MOCK_PAYOUT !== "true") { + return res.status(400).json({ + message: "Influencer has not set up a Stripe account for payouts. Please ask them to connect Stripe in their Earning's dashboard." + }); + } + + // Metadata for post-payment order creation + const metadata = { + gigRequestId: gigRequest._id.toString(), + gigId: gig._id.toString(), + buyerId: gigRequest.brandId.toString(), + influencerId: gigRequest.influencerId.toString(), + amount: amount.toString(), + // Optional: due date from latest accepted proposal + dueDate: req.body.dueDate || "", + }; + + const session = await createCheckoutSession(amount, influencerProfile?.stripeAccountId || "mock_account", metadata); + + res.json({ url: session.url }); + } catch (err: unknown) { + const error = err as Error & { statusCode?: number }; + console.error("Checkout Error:", error); + next(error); + } +}; + + +// 🔥 STRIPE WEBHOOK (ESCROW LOGIC) +export const stripeWebhook = async (req: Request, res: Response) => { + const sig = req.headers["stripe-signature"]; + + let event; + + try { + event = stripe.webhooks.constructEvent( + req.body, + sig as string, + process.env.STRIPE_WEBHOOK_SECRET! + ); + } catch (err) { + console.error("Webhook Error:", err); + return res.status(400).send("Webhook Error"); + } + + if (event.type === "checkout.session.completed") { + const session = event.data.object as Stripe.Checkout.Session; + + // Extract metadata + const { gigRequestId, gigId, buyerId, influencerId, amount: amountStr, dueDate } = session.metadata || {}; + + if (!gigRequestId || !gigId || !buyerId || !influencerId) { + console.error("Missing metadata in Stripe session:", session.id); + return res.json({ status: "metadata missing" }); + } + + // ✅ IDEMPOTENCY: Check if order already exists for this gigRequest + let order = await OrderModel.findOne({ connectionId: gigRequestId }); + + if (order && order.status === "IN_ESCROW") { + return res.json({ status: "already processed" }); + } + + const amount = parseFloat(amountStr || "0"); + + // 🔥 CREATE ORDER RECORD POST-PAYMENT + if (!order) { + const orderData: CreateOrderInput = { + gigId: gigId as string, + buyerId: buyerId as string, + influencerId: influencerId as string, + connectionId: gigRequestId as string, + amount, + }; + if (dueDate) { + orderData.dueDate = dueDate as string; + } + const result = await createOrderService(orderData); + order = await OrderModel.findById(result.orderId); + } + + if (!order) { + console.error("Failed to create order in webhook for session:", session.id); + return res.status(500).json({ message: "Order creation failed" }); + } + + const orderId = order._id; + + // 🔥 ESCROW STARTS HERE (10% Fee) + const PLATFORM_FEE_PERCENTAGE = 0.10; + const platformFee = amount * PLATFORM_FEE_PERCENTAGE; + const influencerAmount = amount - platformFee; + + order.status = "IN_ESCROW"; + order.escrowStatus = "HOLD"; + order.stripePaymentIntentId = session.payment_intent as string; + order.platformFee = platformFee; + order.influencerAmount = influencerAmount; + + await order.save(); + + // 🔥 VIRTUAL BALANCE UPDATES + const profile = await InfluencerProfile.findOne({ userId: order.influencerId }); + if (profile) { + profile.balance = (profile.balance || 0) + influencerAmount; + profile.totalEarnings = (profile.totalEarnings || 0) + influencerAmount; + await profile.save(); + } + + let platform = await PlatformRevenueModel.findOne(); + if (!platform) { + platform = new PlatformRevenueModel(); + } + platform.totalRevenue = (platform.totalRevenue || 0) + platformFee; + platform.availableBalance = (platform.availableBalance || 0) + platformFee; + await platform.save(); + + console.log(`✅ Order ${orderId} IN_ESCROW. Fee added: ${platformFee}, Influencer cut: ${influencerAmount}`); + + // 🔥 REAL-TIME NOTIFICATION + await publishEvent("payment.confirmed", { + orderId: order._id.toString(), + influencerId: order.influencerId.toString(), + amount: order.amount, + }); + + // 🔥 AUTOMATED SYSTEM CHAT MESSAGE + await MessageModel.create({ + gigRequestId: order.connectionId, + senderId: order.buyerId, + receiverId: order.influencerId, + content: `₹${order.amount.toLocaleString()} has been safely secured in platform Escrow!`, + messageType: "SYSTEM", + status: "SENT", + }); + } + + res.json({ received: true }); +}; + +// 🔥 STRIPE CONNECT ONBOARDING +export const createStripeAccountLink = async (req: AuthRequest, res: Response) => { + try { + const userId = req.user!.userId; + const profile = await InfluencerProfile.findOne({ userId }); + if (!profile) return res.status(404).json({ message: "Profile not found" }); + + let stripeAccountId = profile.stripeAccountId; + + // 🔥 AUTO-HEAL: If account ID exists, verify it still exists in this Stripe environment + if (stripeAccountId) { + try { + await stripe.accounts.retrieve(stripeAccountId); + } catch (err: unknown) { + const error = err as Error; + console.warn(`Old Stripe account ${stripeAccountId} is invalid. Resetting...: ${error.message}`); + stripeAccountId = ""; // Mark as empty to trigger new creation below + } + } + + // If no account exists (or was just reset), create a new Express account + if (!stripeAccountId) { + const account = await stripe.accounts.create({ + type: "express", + capabilities: { + card_payments: { requested: true }, + transfers: { requested: true }, + }, + }); + stripeAccountId = account.id; + profile.stripeAccountId = stripeAccountId; + await profile.save(); + } + + // Create the onboarding link + const accountLink = await stripe.accountLinks.create({ + account: stripeAccountId, + refresh_url: `${process.env.FRONTEND_URL || 'http://localhost:3000'}/influencer-dashboard/earnings?error=stripe_refresh`, + return_url: `${process.env.FRONTEND_URL || 'http://localhost:3000'}/influencer-dashboard/earnings?status=verified`, + type: "account_onboarding", + }); + + res.json({ url: accountLink.url }); + } catch (err: unknown) { + const error = err as Error; + console.error("Stripe Connect Error:", error); + res.status(500).json({ message: error.message || "Failed to create Stripe link" }); + } +}; \ No newline at end of file diff --git a/apps/core-api/src/controllers/payout.controller.ts b/apps/core-api/src/controllers/payout.controller.ts new file mode 100644 index 0000000..529ce64 --- /dev/null +++ b/apps/core-api/src/controllers/payout.controller.ts @@ -0,0 +1,105 @@ +import type { Response } from "express"; + +import { stripe } from "../lib/stripe.js"; +import { OrderModel } from "../models/order.model.js"; +import { InfluencerProfile } from "../models/influencer.model.js"; +import type { AuthRequest } from "../middlewares/auth.middleware.js"; + +/** + * 🔥 MANUAL WITHDRAWAL (FULL BALANCE MVP) + * Influencer withdraws ALL eligible "AVAILABLE" funds at once. + */ +export const withdrawBalance = async (req: AuthRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + // 1. Fetch Influencer Profile for Stripe ID + const profile = await InfluencerProfile.findOne({ userId }); + if (!profile?.stripeAccountId) { + return res.status(400).json({ message: "No connected Stripe account found." }); + } + + // 2. Fetch all COMPLETED orders with AVAILABLE payoutStatus + const eligibleOrders = await OrderModel.find({ + influencerId: userId, + status: "COMPLETED", + payoutStatus: "AVAILABLE", + }); + + if (eligibleOrders.length === 0) { + return res.status(400).json({ message: "No funds available for withdrawal." }); + } + + // 3. Calculate total amount to pay out + const totalAmount = eligibleOrders.reduce((sum, order) => sum + (order.influencerAmount || 0), 0); + + if (totalAmount <= 0) { + return res.status(400).json({ message: "Available balance is zero." }); + } + + // 4. LOCK ORDERS (Idempotency) + const orderIds = eligibleOrders.map(o => o._id); + await OrderModel.updateMany( + { _id: { $in: orderIds } }, + { payoutStatus: "PROCESSING" } + ); + + const isMockMode = process.env.STRIPE_MOCK_PAYOUT === "true" || profile.stripeAccountId === "acct_test_123"; + + try { + let payoutId = "po_mock_success_123"; + + // 5. TRIGGER STRIPE PAYOUT (Bypass if mock mode) + if (isMockMode) { + console.log("🛠️ MOCK MODE: Bypassing real Stripe payout"); + } else { + // Note: Stripe Payouts use the smallest currency unit (cents/paise) + const payout = await stripe.payouts.create( + { + amount: Math.round(totalAmount * 100), + currency: "inr", + }, + { + stripeAccount: profile.stripeAccountId, + } + ); + payoutId = payout.id; + } + + // 6. UPDATE ORDERS ON SUCCESS + await OrderModel.updateMany( + { _id: { $in: orderIds } }, + { + payoutStatus: "PAID", + stripePayoutId: payoutId + } + ); + + res.json({ + message: "Withdrawal successful! (MOCK MODE enabled for acct_test_123)", + payoutId: payoutId, + amount: totalAmount, + }); + + } catch (stripeError: unknown) { + const error = stripeError as Error; + console.error("Stripe Payout Error:", error); + + // 🚨 REVERT ON FAILURE + await OrderModel.updateMany( + { _id: { $in: orderIds } }, + { payoutStatus: "AVAILABLE" } + ); + + res.status(500).json({ + message: "Stripe payout failed. Please try again later.", + error: error.message + }); + } + + } catch (error: unknown) { + console.error("Withdrawal Error:", error); + res.status(500).json({ message: "An error occurred during withdrawal." }); + } +}; diff --git a/apps/core-api/src/controllers/profile.controller.ts b/apps/core-api/src/controllers/profile.controller.ts new file mode 100644 index 0000000..4e403d5 --- /dev/null +++ b/apps/core-api/src/controllers/profile.controller.ts @@ -0,0 +1,93 @@ +import type { Response, NextFunction } from "express"; + +import { + getMyProfileService, + updateProfileService, + getPublicInfluencerProfileService +} from "../services/profile.service.js"; +import type { AuthRequest } from "../middlewares/auth.middleware.js"; + + +export const getMyProfileController = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + + if (!req.user) { + return res.status(401).json({ + success: false, + message: "Unauthorized", + }); + } + + const userId = req.user.userId; + + const profile = await getMyProfileService(userId); + + res.status(200).json({ + success: true, + data: profile + }); + + } catch (error) { + next(error); + } +}; + + +export const updateProfileController = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + + if (!req.user) { + return res.status(401).json({ + success: false, + message: "Unauthorized", + }); + } + + const userId = req.user.userId; + + const profile = await updateProfileService(userId, req.body); + + res.status(200).json({ + success: true, + data: profile + }); + + } catch (error) { + next(error); + } +}; + +export const getPublicInfluencerProfileController = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const influencerId = req.params.id as string; + + if (!influencerId) { + return res.status(400).json({ + success: false, + message: "Influencer ID is required", + }); + } + + const data = await getPublicInfluencerProfileService(influencerId); + + res.status(200).json({ + success: true, + data + }); + + } catch (error) { + next(error); + } +}; diff --git a/apps/core-api/src/controllers/report.controller.ts b/apps/core-api/src/controllers/report.controller.ts new file mode 100644 index 0000000..85b169d --- /dev/null +++ b/apps/core-api/src/controllers/report.controller.ts @@ -0,0 +1,140 @@ +import type { Response, NextFunction } from "express"; + +import { createReportService, updateReportStatusService, resolveReportService, getReportsService, getReportByIdService } from "../services/report.service.js"; +import type { AuthRequest } from "../middlewares/auth.middleware.js"; + +export const createReportController = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + if (!req.user) throw new Error("Unauthorized"); + + const report = await createReportService(req.user, req.body); + + res.status(201).json({ + success: true, + data: report + }); + } catch (error) { + next(error); + } +}; + + +export const updateReportStatusController = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + if (!req.user) throw new Error("Unauthorized"); + + const { id } = req.params; + + if (typeof id !== "string") { + throw new Error("Invalid report ID"); + } + + const report = await updateReportStatusService( + req.user.userId, + id + ); + + res.status(200).json({ + success: true, + data: report + }); + + } catch (error) { + next(error); + } +}; + + +//=============RESOLVE REPORT CONTROLLER============= + +export const resolveReportController = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + if (!req.user) throw new Error("Unauthorized"); + + const { id } = req.params; + + if (typeof id !== "string") { + throw new Error("Invalid report ID"); + } + + const { resolution, adminNotes } = req.body; + + const report = await resolveReportService( + req.user.userId, + id, + resolution, + adminNotes + ); + + res.status(200).json({ + success: true, + data: report + }); + + } catch (error) { + next(error); + } +} + +//=============GET REPORTS CONTROLLER============= + +export const getReportsController = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const { status, entityType } = req.query; + + const reports = await getReportsService({ + status: status as string, + entityType: entityType as string + }); + + res.status(200).json({ + success: true, + data: reports + }); + + } catch (error) { + next(error); + } +}; + +//=============GET REPORT BY ID CONTROLLER============== + +export const getReportByIdController = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const { id } = req.params; + + if (typeof id !== "string") { + throw new Error("Invalid report ID"); + } + + const report = await getReportByIdService(id); + + res.status(200).json({ + success: true, + data: report + }); + + } catch (error) { + next(error); + } +}; \ No newline at end of file diff --git a/apps/core-api/src/controllers/search.controller.ts b/apps/core-api/src/controllers/search.controller.ts new file mode 100644 index 0000000..6a6a433 --- /dev/null +++ b/apps/core-api/src/controllers/search.controller.ts @@ -0,0 +1,109 @@ +import type { Request, Response, NextFunction } from "express"; + +import { searchIndex } from "../search/services/search.service.js"; +import { buildGigFilters } from "../search/filters/gig.filter.js"; +import { buildInfluencerFilters } from "../search/filters/influencer.filter.js"; + +// ================= SEARCH GIGS ================= +export const searchGigs = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { q = "", page = 1, limit = 10 } = req.query; + + const filters = buildGigFilters(req.query); + + const options: { limit?: number; offset?: number; filter?: string[] } = { + limit: Number(limit), + offset: (Number(page) - 1) * Number(limit), + }; + + if (filters.length > 0) { + options.filter = filters; + } + + const result = await searchIndex("gigs", q as string, options); + + res.status(200).json({ + hits: result.hits, + total: result.estimatedTotalHits, + page: Number(page), + limit: Number(limit), + }); + + } catch (error) { + next(error); + } +}; + +// ================= SEARCH INFLUENCERS ================= +export const searchInfluencers = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { q = "", page = 1, limit = 10 } = req.query; + + const filters = buildInfluencerFilters(req.query); + + const options: { + limit?: number; + offset?: number; + filter?: string[]; + } = { + limit: Number(limit), + offset: (Number(page) - 1) * Number(limit), + }; + + if (filters.length > 0) { + options.filter = filters; + } + + const result = await searchIndex("influencers", q as string, options); + + res.status(200).json({ + hits: result.hits, + total: result.estimatedTotalHits, + page: Number(page), + limit: Number(limit), + }); + + } catch (error) { + next(error); + } +}; + +// ================= SEARCH BRANDS ================= +export const searchBrands = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { q = "", page = 1, limit = 10 } = req.query; + + const options: { + limit?: number; + offset?: number; + } = { + limit: Number(limit), + offset: (Number(page) - 1) * Number(limit), + }; + + // 🔥 MAIN LINE + const result = await searchIndex("brands", q as string, options); + + res.status(200).json({ + hits: result.hits, + total: result.estimatedTotalHits, + page: Number(page), + limit: Number(limit), + }); + + } catch (error) { + next(error); + } +}; \ No newline at end of file diff --git a/apps/core-api/src/controllers/user.controller.ts b/apps/core-api/src/controllers/user.controller.ts new file mode 100644 index 0000000..cf5e345 --- /dev/null +++ b/apps/core-api/src/controllers/user.controller.ts @@ -0,0 +1,42 @@ +import type { Response } from "express"; + +import { MessageModel } from "../models/chat.model.js"; +import { GigRequestModel } from "../models/gig-request.model.js"; +import type { AuthRequest } from "../middlewares/auth.middleware.js"; + +export const getDashboardCounts = async (req: AuthRequest, res: Response) => { + if (!req.user) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const userId = req.user.userId; + + try { + // 1. Unread messages count + const unreadMessagesCount = await MessageModel.countDocuments({ + receiverId: userId, + status: { $ne: "READ" }, + }); + + // 2. Pending requests count + // For Influencers: Incoming requests from Brands + // For Brands: Outgoing requests to Influencers (though usually we show incoming to influencers) + // The user said "both influencer and brand on sidebar message or booking count indication" + // Let's count pending requests where the user is either brand or influencer + const pendingRequestsCount = await GigRequestModel.countDocuments({ + $or: [{ brandId: userId }, { influencerId: userId }], + status: "pending", + }); + + res.json({ + success: true, + data: { + unreadMessagesCount, + pendingRequestsCount, + }, + }); + } catch (error) { + console.error("Error fetching dashboard counts:", error); + res.status(500).json({ success: false, message: "Internal server error" }); + } +}; diff --git a/apps/core-api/src/db/connect.ts b/apps/core-api/src/db/connect.ts index 701858b..70c0316 100644 --- a/apps/core-api/src/db/connect.ts +++ b/apps/core-api/src/db/connect.ts @@ -10,8 +10,10 @@ if (!mongoUri) { export const connectDB = async () => { try { - await mongoose.connect(mongoUri); + await mongoose.connect(mongoUri, { dbName: "noillin" }); logger.info("MongoDB connected successfully"); + logger.info(`CONNECTED DB: ${mongoose.connection.name}`); + } catch (err: unknown) { logger.error(`MongoDB connection failed: ${String(err)}`); process.exit(1); diff --git a/apps/core-api/src/lib/s3.client.ts b/apps/core-api/src/lib/s3.client.ts new file mode 100644 index 0000000..400c57e --- /dev/null +++ b/apps/core-api/src/lib/s3.client.ts @@ -0,0 +1,9 @@ +import { S3Client } from "@aws-sdk/client-s3"; + +export const s3Client = new S3Client({ + region: process.env.AWS_REGION!, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY! + } +}); \ No newline at end of file diff --git a/apps/core-api/src/lib/stripe.ts b/apps/core-api/src/lib/stripe.ts new file mode 100644 index 0000000..99759b1 --- /dev/null +++ b/apps/core-api/src/lib/stripe.ts @@ -0,0 +1,4 @@ +import Stripe from "stripe"; + +export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); +console.log("STRIPE KEY:", process.env.STRIPE_SECRET_KEY); \ No newline at end of file diff --git a/apps/core-api/src/middlewares/auth.middleware.ts b/apps/core-api/src/middlewares/auth.middleware.ts index 12edcb9..49abc5f 100644 --- a/apps/core-api/src/middlewares/auth.middleware.ts +++ b/apps/core-api/src/middlewares/auth.middleware.ts @@ -2,9 +2,12 @@ import type { Request, Response, NextFunction } from "express"; import { verifyAccessToken } from "../modules/auth/auth.utils.js"; import type { JwtPayload } from "../modules/auth/auth.utils.js"; +import { RolePermissions } from "../rbac/role-permission.js"; +import type { Permission } from "../rbac/permission.js"; export interface AuthRequest extends Request { user?: JwtPayload; + files?: unknown; } interface HttpError extends Error { @@ -38,8 +41,23 @@ export const authenticate = ( next(); } catch (error) { const err = error as HttpError; - if (!err.statusCode) err.statusCode = 401; - next(err); -} + if (!err.statusCode) err.statusCode = 401; + next(err); + } + +}; +export const authorizePermission = (permission: Permission) => { + return (req: AuthRequest, _res: Response, next: NextFunction) => { + if (!req.user) { + return next(Object.assign(new Error("Unauthorized"), { statusCode: 401 })); + } + const permissions = RolePermissions[req.user.role] ?? []; + + if (!permissions.includes(permission)) { + return next(Object.assign(new Error("Forbidden"), { statusCode: 403 })); + } + + next(); + }; }; diff --git a/apps/core-api/src/models/availability.model.ts b/apps/core-api/src/models/availability.model.ts new file mode 100644 index 0000000..7a952a7 --- /dev/null +++ b/apps/core-api/src/models/availability.model.ts @@ -0,0 +1,122 @@ +// import { Schema, model } from "mongoose"; + +// import type { AvailabilityDocument } from "../types/availability.types.js"; + +// const TimeSlotSchema = new Schema( +// { +// startTime: { +// type: String, +// required: true +// }, +// endTime: { +// type: String, +// required: true +// } +// }, +// { _id: false } +// ); + +// const WeeklyRuleSchema = new Schema( +// { +// day: { +// type: String, +// enum: [ +// "monday", +// "tuesday", +// "wednesday", +// "thursday", +// "friday", +// "saturday", +// "sunday" +// ], +// required: true +// }, +// isEnabled: { +// type: Boolean, +// default: false +// }, +// slots: { +// type: [TimeSlotSchema], +// default: [] +// } +// }, +// { _id: false } +// ); + +// const DateOverrideSchema = new Schema( +// { +// date: { +// type: String, +// required: true +// }, +// isAvailable: { +// type: Boolean, +// required: true +// }, +// slots: { +// type: [TimeSlotSchema], +// default: [] +// } +// }, +// { _id: false } +// ); + +// const AvailabilitySchema = new Schema( +// { +// influencerProfileId: { +// type: Schema.Types.ObjectId, +// ref: "InfluencerProfile", +// required: true, +// unique: true, +// index: true +// }, + +// timezone: { +// type: String, +// required: true +// }, + +// weeklyRules: { +// type: [WeeklyRuleSchema], +// default: [] +// }, + +// dateOverrides: { +// type: [DateOverrideSchema], +// default: [] +// } +// }, +// { timestamps: true } +// ); + +// export const AvailabilityModel = model( +// "Availability", +// AvailabilitySchema +// ); + +import { Schema, model } from "mongoose"; + +const availabilitySchema = new Schema( + { + influencerId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + unique: true, + }, + + // Only store unavailable dates + overrides: [ + { + date: { type: Date, required: true }, + reason: { type: String }, + }, + ], + }, + { timestamps: true } +); + +export const AvailabilityModel = model( + "Availability", + availabilitySchema +); \ No newline at end of file diff --git a/apps/core-api/src/models/brand.model.ts b/apps/core-api/src/models/brand.model.ts new file mode 100644 index 0000000..bd35a3c --- /dev/null +++ b/apps/core-api/src/models/brand.model.ts @@ -0,0 +1,116 @@ +import { Schema, model, Types, Document } from "mongoose"; + +export interface IBrandProfile extends Document { + userId: Types.ObjectId; + + companyName: string; + slug?: string; + industry: string; + website?: string; + + contactPersonName: string; + contactEmail: string; + contactPhone?: string; + + businessRegistrationNumber?: string; + gstNumber?: string; + companySize?: string; + + description?: string; + headquarters?: string; + + documents: string[]; + + profileImageUrl?: string; + + isProfileComplete: boolean; + isVerified: boolean; +} + +const BrandProfileSchema = new Schema( + { + userId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + unique: true, + index: true, + }, + + companyName: { + type: String, + required: true, + trim: true, + index: true, + }, + + slug: { + type: String, + unique: true, + index: true, + }, + + industry: { + type: String, + required: true, + trim: true, + }, + + website: { + type: String, + trim: true, + }, + + contactPersonName: { + type: String, + required: true, + trim: true, + }, + + contactEmail: { + type: String, + required: true, + lowercase: true, + trim: true, + }, + + contactPhone: String, + + businessRegistrationNumber: String, + gstNumber: String, + + companySize: { + type: String, + trim: true, + }, + + description: String, + headquarters: String, + + documents: { + type: [String], + default: [], + }, + + profileImageUrl: { + type: String, + default: "", + }, + + isProfileComplete: { + type: Boolean, + default: false, + }, + + isVerified: { + type: Boolean, + default: false, + }, + }, + { timestamps: true } +); + +export const BrandProfile = model( + "BrandProfile", + BrandProfileSchema +); \ No newline at end of file diff --git a/apps/core-api/src/models/chat.model.ts b/apps/core-api/src/models/chat.model.ts new file mode 100644 index 0000000..0d3f131 --- /dev/null +++ b/apps/core-api/src/models/chat.model.ts @@ -0,0 +1,103 @@ +import { Schema, model, Types } from "mongoose"; + +export interface MessageDocument { + _id: Types.ObjectId; + gigRequestId: Types.ObjectId; + + senderId: Types.ObjectId; + receiverId: Types.ObjectId; + + content: string; + + messageType?: "TEXT" | "PROPOSAL" | "SYSTEM" | "DELIVERABLE" | "ORDER_COMPLETED"; + proposalData?: { + date: Date; + time: string; + status: "PENDING" | "ACCEPTED" | "REJECTED"; + }; + deliverableData?: { + url: string; + mediaType: "VIDEO" | "IMAGE"; + status: "PENDING" | "ACCEPTED" | "REJECTED"; + rejectionNote?: string; + }; + + status: "SENT" | "DELIVERED" | "READ"; + + createdAt: Date; + updatedAt: Date; +} + +const MessageSchema = new Schema( + { + gigRequestId: { + type: Schema.Types.ObjectId, + ref: "GigRequest", + required: true, + index: true, + }, + + senderId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + index: true + }, + + receiverId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + index: true + }, + + content: { + type: String, + required: true + }, + + messageType: { + type: String, + enum: ["TEXT", "PROPOSAL", "SYSTEM", "DELIVERABLE", "ORDER_COMPLETED"], + default: "TEXT" + }, + + proposalData: { + date: Date, + time: String, + status: { + type: String, + enum: ["PENDING", "ACCEPTED", "REJECTED"], + } + }, + + deliverableData: { + url: String, + mediaType: { + type: String, + enum: ["VIDEO", "IMAGE"], + }, + status: { + type: String, + enum: ["PENDING", "ACCEPTED", "REJECTED"], + default: "PENDING" + }, + rejectionNote: String + }, + + status: { + type: String, + enum: ["SENT", "DELIVERED", "READ"], + default: "SENT", + index: true + } + }, + { + timestamps: true + } +); + +export const MessageModel = model( + "Message", + MessageSchema +); \ No newline at end of file diff --git a/apps/core-api/src/models/collaboration.model.ts b/apps/core-api/src/models/collaboration.model.ts new file mode 100644 index 0000000..074957e --- /dev/null +++ b/apps/core-api/src/models/collaboration.model.ts @@ -0,0 +1,53 @@ +import { Schema, model } from "mongoose"; + +import type { GigCollaborationDocument } from "../types/collaboration.type.js"; + + +const GigCollaborationSchema = + new Schema( + { + gigId: { + type: Schema.Types.ObjectId, + ref: "Gig", + required: true, + index: true + }, + + primaryInfluencerId: { + type: Schema.Types.ObjectId, + ref: "InfluencerProfile", + required: true, + index: true + }, + + invitedInfluencerId: { + type: Schema.Types.ObjectId, + ref: "InfluencerProfile", + required: true, + index: true + }, + + status: { + type: String, + enum: ["pending", "accepted", "rejected", "cancelled"], + default: "pending", + index: true + }, + + respondedAt: { + type: Date + } + }, + { timestamps: true } + ); + +// Prevent duplicate invitations +GigCollaborationSchema.index( + { gigId: 1, invitedInfluencerId: 1 }, + { unique: true } +); + +export const GigCollaborationModel = model( + "GigCollaboration", + GigCollaborationSchema +); diff --git a/apps/core-api/src/models/connection.model.ts b/apps/core-api/src/models/connection.model.ts new file mode 100644 index 0000000..12b5f68 --- /dev/null +++ b/apps/core-api/src/models/connection.model.ts @@ -0,0 +1,45 @@ +import { Schema, model } from "mongoose"; + + +const connectionSchema = new Schema( + { + brandId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + + influencerId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + + gigId: { + type: Schema.Types.ObjectId, + ref: "Gig", + }, + + note: { + type: String, + }, + + status: { + type: String, + enum: ["pending", "accepted", "rejected"], + default: "pending", + }, + }, + { timestamps: true } +); + +// Prevent duplicate requests +connectionSchema.index( + { brandId: 1, influencerId: 1, gigId: 1 }, + { unique: true } +); + +export const ConnectionModel = model( + "Connection", + connectionSchema +); \ No newline at end of file diff --git a/apps/core-api/src/models/gig-request.model.ts b/apps/core-api/src/models/gig-request.model.ts new file mode 100644 index 0000000..0ba7941 --- /dev/null +++ b/apps/core-api/src/models/gig-request.model.ts @@ -0,0 +1,42 @@ +import { Schema, model } from "mongoose"; + +const gigRequestSchema = new Schema( + { + brandId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + + influencerId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + + gigId: { + type: Schema.Types.ObjectId, + ref: "Gig", + required: true, + }, + + note: { + type: String, + }, + + status: { + type: String, + enum: ["pending", "accepted", "rejected"], + default: "pending", + }, + }, + { timestamps: true } +); + +// One request per brand-influencer-gig combo +gigRequestSchema.index( + { brandId: 1, influencerId: 1, gigId: 1 }, + { unique: true } +); + +export const GigRequestModel = model("GigRequest", gigRequestSchema); diff --git a/apps/core-api/src/models/gig.model.ts b/apps/core-api/src/models/gig.model.ts new file mode 100644 index 0000000..eb9830b --- /dev/null +++ b/apps/core-api/src/models/gig.model.ts @@ -0,0 +1,153 @@ +import { Schema, model } from "mongoose"; + +import type { GigDocument } from "../types/gig.type.js"; + +const DeliverableSchema = new Schema( + { + contentType: { + type: String, + required: true + }, + quantity: { + type: Number, + required: true, + min: 1 + }, + includedItems: [ + { + type: String + } + ] + }, + { _id: false } +); + +const GigSchema = new Schema( + { + title: { + type: String, + required: true, + trim: true + }, + + shortDescription: { + type: String, + required: true, + maxlength: 180 + }, + + platform: { + type: String, + enum: ["instagram", "youtube", "tiktok"], + required: true, + index: true + }, + + gigType: { + type: String, + enum: ["solo", "collaboration"], + required: true, + index: true + }, + + influencerIds: [ + { + type: Schema.Types.ObjectId, + ref: "InfluencerProfile", + required: true + } + ], + + primaryInfluencerId: { + type: Schema.Types.ObjectId, + ref: "InfluencerProfile", + required: true, + index: true + }, + + category: { + type: String, + required: true, + index: true + }, + + tags: [ + { + type: String, + index: true + } + ], + + deliverables: { + type: [DeliverableSchema], + default: [] + }, + + pricing: { + basePrice: { + type: Number, + required: true, + min: 0 + }, + currency: { + type: String, + enum: ["INR", "USD"], + required: true + }, + negotiationAllowed: { + type: Boolean, + default: false + }, + deliveryTimeInDays: { + type: Number, + required: true, + min: 1 + }, + revisionsIncluded: { + type: Number, + required: true, + min: 0 + } + }, + + maxBookingsPerSlot: { + type: Number, + min: 1 + }, + + status: { + type: String, + enum: [ + "draft", + "published", // ACTIVE + "flagged", + "paused", + "under_review", + "rejected", + "archived" + ], default: "draft", + index: true + }, + + isDeleted: { + type: Boolean, + default: false, + index: true + }, reportCount: { + type: Number, + default: 0, + index: true + }, + bannerUrl: { + type: String, + default: "" + } + }, + + { timestamps: true } +); + +GigSchema.index({ influencerIds: 1 }); +GigSchema.index({ "pricing.basePrice": 1 }); + +export const GigModel = model("Gig", GigSchema); \ No newline at end of file diff --git a/apps/core-api/src/models/influencer.model.ts b/apps/core-api/src/models/influencer.model.ts new file mode 100644 index 0000000..d5b020e --- /dev/null +++ b/apps/core-api/src/models/influencer.model.ts @@ -0,0 +1,109 @@ +import { Schema, model, Types, Document } from "mongoose"; + +export interface IInfluencerProfile extends Document { + userId: Types.ObjectId; + + fullName: string; + username: string; + bio?: string; + + instagramUrl?: string; + youtubeUrl?: string; + tiktokUrl?: string; + + categories: string[]; + location?: string; + languages: string[]; + + followersCount?: number; + engagementRate?: number; + + isProfileComplete: boolean; + isVerified: boolean; + profileImageUrl?: string; + stripeAccountId?: string; + balance?: number; + totalEarnings?: number; +} + +const InfluencerProfileSchema = new Schema( + { + userId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + unique: true, + index: true, + }, + + fullName: { + type: String, + default: "", + trim: true, + }, + + username: { + type: String, + required: true, + unique: true, + trim: true, + lowercase: true, + }, + + bio: { + type: String, + maxlength: 500, + }, + profileImageUrl: { + type: String, + default: "" + }, + + instagramUrl: String, + youtubeUrl: String, + tiktokUrl: String, + + categories: { + type: [String], + default: [], + }, + + location: String, + + languages: { + type: [String], + default: [], + }, + + followersCount: Number, + engagementRate: Number, + + isProfileComplete: { + type: Boolean, + default: false, + }, + + isVerified: { + type: Boolean, + default: false, + }, + stripeAccountId: { + type: String, + default: "", + }, + balance: { + type: Number, + default: 0, + }, + totalEarnings: { + type: Number, + default: 0, + }, + }, + { timestamps: true } +); + +export const InfluencerProfile = model( + "InfluencerProfile", + InfluencerProfileSchema +); \ No newline at end of file diff --git a/apps/core-api/src/models/notification.model.ts b/apps/core-api/src/models/notification.model.ts new file mode 100644 index 0000000..35fe439 --- /dev/null +++ b/apps/core-api/src/models/notification.model.ts @@ -0,0 +1,39 @@ +import { Schema, model } from "mongoose"; + +const notificationSchema = new Schema( + { + userId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + type: { + type: String, + required: true, + enum: ["gig_request", "system", "GIG_REQUEST", "NEW_MESSAGE", "ORDER_CREATED", "USER_PENDING_APPROVAL", "USER_APPROVED"], // Extensible for later + default: "system", + }, + title: { + type: String, + required: true, + }, + message: { + type: String, + required: true, + }, + metadata: { + type: Schema.Types.Mixed, + default: {}, + }, + isRead: { + type: Boolean, + default: false, + }, + }, + { timestamps: true } +); + +// Index for efficient fetching of user notifications +notificationSchema.index({ userId: 1, createdAt: -1 }); + +export const NotificationModel = model("Notification", notificationSchema); diff --git a/apps/core-api/src/models/order.model.ts b/apps/core-api/src/models/order.model.ts new file mode 100644 index 0000000..5b5076b --- /dev/null +++ b/apps/core-api/src/models/order.model.ts @@ -0,0 +1,58 @@ +import mongoose from "mongoose"; +import { model } from "mongoose"; + +import type { OrderDocument } from "../types/order.types.js"; + +const orderSchema = new mongoose.Schema( + { + buyerId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, + influencerId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, + gigId: { type: mongoose.Schema.Types.ObjectId, ref: "Gig", required: true }, + + // 🔥 ADD THIS + connectionId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Connection", + required: true, + }, + dueDate: Date, // 🔥 AGREED DELIVERY DATE + + amount: { type: Number, required: true }, + currency: { type: String, default: "INR" }, + + status: { + type: String, + enum: ["PENDING", "IN_ESCROW", "COMPLETED", "CANCELLED", "DISPUTED"], + default: "PENDING", + }, + + escrowStatus: { + type: String, + enum: ["HOLD", "RELEASED"], + default: "HOLD", + }, + + workStatus: { + type: String, + enum: ["NOT_STARTED", "SUBMITTED", "APPROVED", "REJECTED"], + default: "NOT_STARTED", + }, + deliverableUrl: String, + rejectionNote: String, + + stripePaymentIntentId: String, + platformFee: Number, // 5% fee + influencerAmount: Number, // 95% share + + payoutStatus: { + type: String, + enum: ["HOLD", "AVAILABLE", "PROCESSING", "PAID"], + default: "HOLD", + }, + availableAt: Date, + stripePayoutId: String, + }, + { timestamps: true } +); + +export const OrderModel = model("Order", orderSchema); diff --git a/apps/core-api/src/models/pendingSignup.models.ts b/apps/core-api/src/models/pendingSignup.models.ts new file mode 100644 index 0000000..d5b0491 --- /dev/null +++ b/apps/core-api/src/models/pendingSignup.models.ts @@ -0,0 +1,140 @@ +import { Schema, model, Document } from "mongoose"; + +export enum PendingSignupStatus { + PENDING = "PENDING", + APPROVED = "APPROVED", + REJECTED = "REJECTED", +} + +export enum PendingSignupRole { + INFLUENCER = "INFLUENCER", + BRAND = "BRAND", +} + +export interface IPendingSignup extends Document { + fullName: string; + email: string; + passwordHash: string; + role: PendingSignupRole; + documents?: string; + status: PendingSignupStatus; + + // OTP fields + emailOtpHash?: string | null; + emailOtpExpiresAt?: Date | null; + otpAttempts: number; + otpResendCount: number; + otpLastSentAt?: Date | null; + otpLockedUntil?: Date | null; + isEmailVerified: boolean; + + profileData?: { + bio?: string; + location?: string; + phoneNumber?: string; + // Influencer specific + username?: string; + niche?: string; + gender?: string; + dob?: string; + socialLinks?: { + instagram?: string; + youtube?: string; + tiktok?: string; + }; + // Brand specific + companyName?: string; + industry?: string; + website?: string; + companySize?: string; + profileImageUrl?: string; + }; + + createdAt: Date; + updatedAt: Date; +} + +const PendingSignupSchema = new Schema( + { + fullName: { + type: String, + required: true, + trim: true, + }, + + email: { + type: String, + required: true, + unique: true, + }, + + passwordHash: { + type: String, + required: true, + }, + + role: { + type: String, + enum: Object.values(PendingSignupRole), + required: true, + }, + + documents: { + type: String, + }, + + status: { + type: String, + enum: Object.values(PendingSignupStatus), + default: PendingSignupStatus.PENDING, + }, + + profileData: { + type: Object, + default: {}, + }, + + // OTP fields + emailOtpHash: { + type: String, + select: false, + default: null, + }, + + emailOtpExpiresAt: { + type: Date, + default: null, + }, + + otpAttempts: { + type: Number, + default: 0, + }, + + otpResendCount: { + type: Number, + default: 0, + }, + + otpLastSentAt: { + type: Date, + default: null, + }, + + otpLockedUntil: { + type: Date, + default: null, + }, + + isEmailVerified: { + type: Boolean, + default: false, + }, + }, + { timestamps: true } +); + +export const PendingSignup = model( + "PendingSignup", + PendingSignupSchema +); \ No newline at end of file diff --git a/apps/core-api/src/models/platform-revenue.model.ts b/apps/core-api/src/models/platform-revenue.model.ts new file mode 100644 index 0000000..ddcea21 --- /dev/null +++ b/apps/core-api/src/models/platform-revenue.model.ts @@ -0,0 +1,25 @@ +import { Schema, model, Document } from "mongoose"; + +export interface IPlatformRevenue extends Document { + totalRevenue: number; + availableBalance: number; +} + +const PlatformRevenueSchema = new Schema( + { + totalRevenue: { + type: Number, + default: 0, + }, + availableBalance: { + type: Number, + default: 0, + }, + }, + { timestamps: true } +); + +export const PlatformRevenueModel = model( + "PlatformRevenue", + PlatformRevenueSchema +); diff --git a/apps/core-api/src/models/push-subscription.model.ts b/apps/core-api/src/models/push-subscription.model.ts new file mode 100644 index 0000000..028db3e --- /dev/null +++ b/apps/core-api/src/models/push-subscription.model.ts @@ -0,0 +1,29 @@ +import { Schema, model } from "mongoose"; + +const pushSubscriptionSchema = new Schema( + { + userId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + endpoint: { + type: String, + required: true, + unique: true, + }, + keys: { + p256dh: { type: String, required: true }, + auth: { type: String, required: true }, + }, + }, + { timestamps: true } +); + +// Index for looking up subscriptions by user +pushSubscriptionSchema.index({ userId: 1 }); + +export const PushSubscriptionModel = model( + "PushSubscription", + pushSubscriptionSchema +); diff --git a/apps/core-api/src/models/report.model.ts b/apps/core-api/src/models/report.model.ts new file mode 100644 index 0000000..3de5f88 --- /dev/null +++ b/apps/core-api/src/models/report.model.ts @@ -0,0 +1,91 @@ +import { Schema, model } from "mongoose"; + +import type { ReportDocument } from "../types/report.types.js"; + +const ReportSchema = new Schema({ + reportId: { + type: String, + unique: true + }, + + entityType: { + type: String, + enum: ["GIG", "ORDER", "USER"], + required: true + }, + + entityId: { + type: Schema.Types.ObjectId, + required: true + }, + + type: { + type: String, + enum: ["CONTENT", "PAYMENT", "BEHAVIOR"], + required: true, + validate: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + validator: function (this: any, value: string) { + if (this.entityType === "ORDER") { + return value === "PAYMENT"; + } + return true; + }, + message: "ORDER reports must be of type PAYMENT" + } + }, + + subType: { + type: String, + enum: ["NOT_RECEIVED", "LOW_QUALITY", "SCAM", "PAYMENT_ISSUE"], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + required: function (this: any) { + return this.type === "PAYMENT"; + } + }, + + reportedBy: { + type: Schema.Types.ObjectId, + ref: "User", + required: true + }, + + usersInvolved: [{ + type: Schema.Types.ObjectId, + ref: "User" + }], + + description: String, + + status: { + type: String, + enum: ["PENDING", "UNDER_REVIEW", "RESOLVED"], + default: "PENDING" + }, + + resolution: { + type: String, + enum: ["VALID", "INVALID"], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + required: function (this: any) { + return this.status === "RESOLVED"; + } + }, + + adminNotes: String, + + auditTrail: [ + { + action: String, + performedBy: Schema.Types.ObjectId, + createdAt: { type: Date, default: Date.now } + } + ], + evidenceUrls:{ + type:[String], + default:[] + } + +}, { timestamps: true }); + +export const ReportModel = model("Report", ReportSchema); \ No newline at end of file diff --git a/apps/core-api/src/models/user.model.ts b/apps/core-api/src/models/user.model.ts new file mode 100644 index 0000000..294c713 --- /dev/null +++ b/apps/core-api/src/models/user.model.ts @@ -0,0 +1,112 @@ +import { Schema, model, Document } from "mongoose"; + +export enum UserRole { + INFLUENCER = "INFLUENCER", + BRAND = "BRAND", + ADMIN = "ADMIN", +} + +export enum AdminLevel { + SUPER = "SUPER", + NORMAL = "NORMAL", +} + +export enum UserStatus { + PENDING = "PENDING", + ACTIVE = "ACTIVE", + SUSPENDED = "SUSPENDED", +} + +export interface IUser extends Document { + email: string; + password: string; + role: UserRole; + adminLevel?: AdminLevel; + isEmailVerified: boolean; + status: UserStatus; + refreshToken?: string; + createdAt: Date; + updatedAt: Date; + + // Forgot Password Fields + resetOtp?: string; + resetOtpExpiry?: Date; + resetSessionToken?: string; + resetSessionExpiry?: Date; +} + +const UserSchema = new Schema( + { + email: { + type: String, + required: true, + unique: true, + lowercase: true, + trim: true, + index: true, + immutable: true, + }, + + password: { + type: String, + required: true, + select: false, // never return password + }, + + role: { + type: String, + enum: Object.values(UserRole), + required: true, + }, + + adminLevel: { + type: String, + enum: Object.values(AdminLevel), + default: null, + }, + + isEmailVerified: { + type: Boolean, + default: false, + }, + + status: { + type: String, + enum: Object.values(UserStatus), + default: UserStatus.PENDING, + }, + + refreshToken: { + type: String, + select: false, + }, + + // ================================ + // FORGOT PASSWORD FIELDS + // ================================ + + resetOtp: { + type: String, + select: false, // never return OTP + }, + + resetOtpExpiry: { + type: Date, + }, + + resetSessionToken: { + type: String, + select: false, // never expose session token + }, + + resetSessionExpiry: { + type: Date, + }, + }, + { timestamps: true } +); + +// compound index (already yours) +UserSchema.index({ role: 1, status: 1 }); + +export const User = model("User", UserSchema); diff --git a/apps/core-api/src/modules/auth/auth.types.ts b/apps/core-api/src/modules/auth/auth.types.ts index fd13016..6dd6f27 100644 --- a/apps/core-api/src/modules/auth/auth.types.ts +++ b/apps/core-api/src/modules/auth/auth.types.ts @@ -1,4 +1,4 @@ -import { UserRole, AdminLevel } from "../users/user.model.js"; +import { UserRole, AdminLevel } from "../../models/user.model.js"; export interface JwtPayload { userId: string; diff --git a/apps/core-api/src/modules/auth/auth.utils.ts b/apps/core-api/src/modules/auth/auth.utils.ts index d39f884..62fca42 100644 --- a/apps/core-api/src/modules/auth/auth.utils.ts +++ b/apps/core-api/src/modules/auth/auth.utils.ts @@ -1,6 +1,6 @@ import jwt from "jsonwebtoken"; -import type { AdminLevel, UserRole } from "../users/user.model.js"; +import type { AdminLevel, UserRole } from "../../models/user.model.js"; const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!; diff --git a/apps/core-api/src/modules/auth/http-error.ts b/apps/core-api/src/modules/auth/http-error.ts index 56b3949..c3cdd56 100644 --- a/apps/core-api/src/modules/auth/http-error.ts +++ b/apps/core-api/src/modules/auth/http-error.ts @@ -1,3 +1,16 @@ +// export interface HttpError extends Error { +// statusCode?: number; +// } + export interface HttpError extends Error { statusCode?: number; } + +export const createHttpError = ( + message: string, + statusCode: number +): HttpError => { + const error = new Error(message) as HttpError; + error.statusCode = statusCode; + return error; +}; \ No newline at end of file diff --git a/apps/core-api/src/modules/users/user.model.ts b/apps/core-api/src/modules/users/user.model.ts index cf162b0..92db70d 100644 --- a/apps/core-api/src/modules/users/user.model.ts +++ b/apps/core-api/src/modules/users/user.model.ts @@ -1,73 +1,110 @@ -import { Schema, model, Document } from "mongoose"; - -export enum UserRole { - INFLUENCER = "INFLUENCER", - BRAND = "BRAND", - ADMIN = "ADMIN", -} - -export enum AdminLevel { - SUPER = "SUPER", - NORMAL = "NORMAL", -} - -export enum UserStatus { - ACTIVE = "ACTIVE", - SUSPENDED = "SUSPENDED", -} - -export interface IUser extends Document { - email: string; - password: string; - role: UserRole; - adminLevel?: AdminLevel; - isEmailVerified: boolean; - status: UserStatus; - refreshToken?: string; -} - -const UserSchema = new Schema( - { - email: { - type: String, - required: true, - unique: true, - lowercase: true, - trim: true, - index: true, - immutable: true, - }, - password: { - type: String, - required: true, - select: false, - }, - role: { - type: String, - enum: Object.values(UserRole), - required: true, - }, - adminLevel: { - type: String, - enum: Object.values(AdminLevel), - default: null, - }, - isEmailVerified: { - type: Boolean, - default: false, - }, - status: { - type: String, - enum: Object.values(UserStatus), - default: UserStatus.ACTIVE, - }, - refreshToken: { - type: String, - select: false, - }, - }, - { timestamps: true } -); -UserSchema.index({ role: 1, status: 1 }); - -export const User = model("User", UserSchema) +import { Schema, model, Document } from "mongoose"; + +export enum UserRole { + INFLUENCER = "INFLUENCER", + BRAND = "BRAND", + ADMIN = "ADMIN", +} + +export enum AdminLevel { + SUPER = "SUPER", + NORMAL = "NORMAL", +} + +export enum UserStatus { + PENDING = "PENDING", + ACTIVE = "ACTIVE", + SUSPENDED = "SUSPENDED", +} + +export interface IUser extends Document { + email: string; + password: string; + role: UserRole; + adminLevel?: AdminLevel; + isEmailVerified: boolean; + status: UserStatus; + refreshToken?: string; + + // 🔐 Forgot Password Fields + resetOtp?: string; + resetOtpExpiry?: Date; + resetSessionToken?: string; + resetSessionExpiry?: Date; +} + +const UserSchema = new Schema( + { + email: { + type: String, + required: true, + unique: true, + lowercase: true, + trim: true, + index: true, + immutable: true, + }, + + password: { + type: String, + required: true, + select: false, // 🔐 never return password + }, + + role: { + type: String, + enum: Object.values(UserRole), + required: true, + }, + + adminLevel: { + type: String, + enum: Object.values(AdminLevel), + default: null, + }, + + isEmailVerified: { + type: Boolean, + default: false, + }, + + status: { + type: String, + enum: Object.values(UserStatus), + default: UserStatus.PENDING, + }, + + refreshToken: { + type: String, + select: false, + }, + + // ================================ + // FORGOT PASSWORD FIELDS + // ================================ + + resetOtp: { + type: String, + select: false, // never return OTP + }, + + resetOtpExpiry: { + type: Date, + }, + + resetSessionToken: { + type: String, + select: false, // never expose session token + }, + + resetSessionExpiry: { + type: Date, + }, + }, + { timestamps: true } +); + +// compound index (already yours) +UserSchema.index({ role: 1, status: 1 }); + +export const User = model("User", UserSchema); diff --git a/apps/core-api/src/queue/events.ts b/apps/core-api/src/queue/events.ts new file mode 100644 index 0000000..e55d90d --- /dev/null +++ b/apps/core-api/src/queue/events.ts @@ -0,0 +1,7 @@ +export const INFLUENCER_CREATED_EVENT = "influencer.created"; +export const GIG_CREATED_EVENT = "gig.created"; +export const BRAND_CREATED_EVENT = "brand.created"; +export const ORDER_CREATED_EVENT = "order.created"; +export const GIG_REQUEST_CREATED_EVENT = "gig_request.created"; +export const GIG_REQUEST_ACCEPTED_EVENT = "gig_request.accepted"; +export const GIG_REQUEST_REJECTED_EVENT = "gig_request.rejected"; \ No newline at end of file diff --git a/apps/core-api/src/queue/notification.queue.ts b/apps/core-api/src/queue/notification.queue.ts new file mode 100644 index 0000000..4f274ea --- /dev/null +++ b/apps/core-api/src/queue/notification.queue.ts @@ -0,0 +1,14 @@ +import { Queue } from "bullmq"; +import type { ConnectionOptions } from "bullmq"; + +import { redis } from "../cache/redis.js"; + +export const notificationQueue = new Queue("notification-queue", { + connection: redis as unknown as ConnectionOptions, +}); +// import { Queue } from "bullmq"; +// import { redis } from "../cache/redis.js"; + +// export const notificationQueue = new Queue("notification-queue", { +// connection: redis as any, +// }); diff --git a/apps/core-api/src/queue/publisher.ts b/apps/core-api/src/queue/publisher.ts new file mode 100644 index 0000000..0a3228e --- /dev/null +++ b/apps/core-api/src/queue/publisher.ts @@ -0,0 +1,19 @@ +// core-api/src/queue/publisher.ts + +import { getChannel } from "./rabbit.js"; + +export async function publishEvent(queue: string, data: unknown) { + const channel = getChannel(); + + if (!channel) { + throw new Error("RabbitMQ channel not initialized"); + } + await channel.assertQueue(queue, { durable: true }); + + console.log("📤 Sending event to queue:", queue); + channel.sendToQueue( + queue, + Buffer.from(JSON.stringify(data)), + { persistent: true } + ); +} \ No newline at end of file diff --git a/apps/core-api/src/queue/rabbit.ts b/apps/core-api/src/queue/rabbit.ts index 2c0c074..ea18765 100644 --- a/apps/core-api/src/queue/rabbit.ts +++ b/apps/core-api/src/queue/rabbit.ts @@ -1,26 +1,42 @@ -import amqp from "amqplib" + import amqp, { type Channel } from "amqplib"; -import { logger } from "../utils/logger.js" + import { logger } from "../utils/logger.js"; + import type { HttpError } from "../modules/auth/http-error.js"; -const rabbitUrl = process.env.RABBIT_URL as string + const rabbitUrl = process.env.RABBIT_URL as string; -export const connectRabbit = async() => { - if (!process.env.RABBIT_URL) { - console.warn("RabbitMQ disabled: RABBIT_URL not set"); - return; + let channel: Channel | null = null; + + + export const connectRabbit = async () => { + if (!rabbitUrl) { + console.warn("RabbitMQ disabled: RABBIT_URL not set"); + return; } - try{ - const connection = await amqp.connect(rabbitUrl) - logger.info("RabbitMQ is connected") - - connection.on("close", () => { - logger.warn("RabbitMQ connection closed"); -}); - - return connection - -} catch (err:unknown){ - logger.error(`RabbitMQ connection failed ${String(err)}`) -} -} + try { + const connection = await amqp.connect(rabbitUrl); + channel = await connection.createChannel(); + + logger.info("RabbitMQ connected (Core API)"); + + connection.on("error", (err) => { + logger.error("RabbitMQ connection error", err); + }); + + connection.on("close", () => { + logger.warn("RabbitMQ connection closed"); + }); + + } catch (err: unknown) { + logger.error(`RabbitMQ connection failed ${String(err)}`); + } + }; + + export function getChannel():Channel{ + if(!channel){ + const err =new Error("RabbitMQ channel not initialized") as HttpError; + throw err; + } + return channel; + } \ No newline at end of file diff --git a/apps/core-api/src/rbac/permission.ts b/apps/core-api/src/rbac/permission.ts new file mode 100644 index 0000000..d674ea4 --- /dev/null +++ b/apps/core-api/src/rbac/permission.ts @@ -0,0 +1,12 @@ +export enum Permission { + CREATE_PROFILE = "CREATE_PROFILE", + UPDATE_PROFILE = "UPDATE_PROFILE", + APPROVE_SIGNUP = "APPROVE_SIGNUP", + REJECT_SIGNUP = "REJECT_SIGNUP", + MANAGE_USERS = "MANAGE_USERS", + CREATE_GIG = "CREATE_GIG", + UPDATE_GIG = "UPDATE_GIG", + PUBLISH_GIG = "PUBLISH_GIG", + DELETE_GIG = "DELETE_GIG", + REPORT_GIG = "REPORT_GIG" +} diff --git a/apps/core-api/src/rbac/role-permission.ts b/apps/core-api/src/rbac/role-permission.ts new file mode 100644 index 0000000..2b61a97 --- /dev/null +++ b/apps/core-api/src/rbac/role-permission.ts @@ -0,0 +1,28 @@ + +import { UserRole } from "../models/user.model.js"; + +import { Permission } from "./permission.js"; + +export const RolePermissions: Record = { + [UserRole.ADMIN]: [ + Permission.CREATE_PROFILE, + Permission.UPDATE_PROFILE, + Permission.APPROVE_SIGNUP, + Permission.REJECT_SIGNUP, + Permission.MANAGE_USERS, + Permission.DELETE_GIG, + ], + [UserRole.BRAND]: [ + Permission.CREATE_PROFILE, + Permission.UPDATE_PROFILE, + ], + [UserRole.INFLUENCER]: [ + Permission.CREATE_PROFILE, + Permission.UPDATE_PROFILE, + Permission.CREATE_GIG, + Permission.UPDATE_GIG, + Permission.DELETE_GIG, + Permission.PUBLISH_GIG, + Permission.REPORT_GIG + ], +}; diff --git a/packages/shared/src/roles.ts b/apps/core-api/src/rbac/roles.ts similarity index 84% rename from packages/shared/src/roles.ts rename to apps/core-api/src/rbac/roles.ts index 47e132f..6e3f85f 100644 --- a/packages/shared/src/roles.ts +++ b/apps/core-api/src/rbac/roles.ts @@ -1,7 +1,7 @@ export const Roles = { ADMIN: "admin", INFLUENCER: "influencer", - BRAND: "brand", + BRAND: "BRAND", } as const; export type Role = (typeof Roles)[keyof typeof Roles]; diff --git a/apps/core-api/src/repositories/Signup.repository.ts b/apps/core-api/src/repositories/Signup.repository.ts new file mode 100644 index 0000000..9502328 --- /dev/null +++ b/apps/core-api/src/repositories/Signup.repository.ts @@ -0,0 +1,77 @@ +import { PendingSignup } from "../models/pendingSignup.models.js"; +import type { PendingSignupFilter } from "../types/pendingSignup.types.js"; + +interface CreatePendingSignupInput { + fullName: string; + email: string; + passwordHash: string; + documents: string; + role: "INFLUENCER" | "BRAND" | "ADMIN"; + adminLevel?: "SUPER" | "NORMAL"; + + status: "PENDING" | "APPROVED" | "REJECTED"; + profileData?: Record; + + // OTP fields (optional) + emailOtpHash?: string | null; + emailOtpExpiresAt?: Date | null; + otpAttempts?: number; + otpResendCount?: number; + otpLastSentAt?: Date | null; + otpLockedUntil?: Date | null; + isEmailVerified?: boolean; +} + + + +class PendingSignupRepository { + // ================= CREATE ================= + create(data: CreatePendingSignupInput) { + return PendingSignup.create(data); + } + + //==================GET ALL PENDING SIGNUPS================== + getAllPendingSignups(filter:PendingSignupFilter={}) { + return PendingSignup.find(filter).sort({ createdAt: -1 }); + } + + // ================= FIND ================= + findByEmail(email: string) { + return PendingSignup + .findOne({ email }) + .select("+emailOtpHash"); + } + + + // ================= UPDATE STATUS ================= + updateStatus(email: string, status: "APPROVED" | "REJECTED") { + return PendingSignup.findOneAndUpdate( + { email }, + { status }, + { new: true } + ); + } + + // ================= DELETE ONE ================= + deleteByEmail(email: string) { + return PendingSignup.findOneAndDelete({ email }); + } + + // ================= UPDATE PROFILE DATA ================= + updateProfileData(email: string, profileData: Record) { + return PendingSignup.findOneAndUpdate( + { email }, + { $set: { profileData } }, + { new: true } + ); + } + + // ================= DELETE MANY (FOR CLEANUP) ================= + deleteMany(filter: Record): Promise { + return PendingSignup.deleteMany(filter); + } + +} + +export const pendingSignupRepository = + new PendingSignupRepository(); diff --git a/apps/core-api/src/repositories/availability.repository.ts b/apps/core-api/src/repositories/availability.repository.ts new file mode 100644 index 0000000..31a44fd --- /dev/null +++ b/apps/core-api/src/repositories/availability.repository.ts @@ -0,0 +1,70 @@ +// import { Types } from "mongoose"; + +// import type { AvailabilityDocument } from "../types/availability.types.js"; +// import { AvailabilityModel } from "../models/availability.model.js"; + +// export const createAvailability = async ( +// data: Omit +// ) => { +// return AvailabilityModel.create(data); +// }; + +// export const findAvailabilityByInfluencer = async ( +// influencerProfileId: Types.ObjectId +// ) => { +// return AvailabilityModel.findOne({ influencerProfileId }); +// }; + +// export const updateAvailability = async ( +// influencerProfileId: Types.ObjectId, +// update: Partial +// ) => { +// return AvailabilityModel.findOneAndUpdate( +// { influencerProfileId }, +// update, +// { new: true } +// ); +// }; + +// export const upsertAvailability = async ( +// influencerProfileId: Types.ObjectId, +// data: Partial +// ) => { +// return AvailabilityModel.findOneAndUpdate( +// { influencerProfileId }, +// data, +// { new: true, upsert: true } +// ); +// }; + +import { AvailabilityModel } from "../models/availability.model.js"; + +export class AvailabilityRepository { + async getAvailability(influencerId: string) { + return AvailabilityModel.findOne({ influencerId }); + } + + async addUnavailableDate(influencerId: string, date: Date, reason?: string) { + return AvailabilityModel.findOneAndUpdate( + { influencerId }, + { + $push: { + overrides: { date, reason }, + }, + }, + { new: true, upsert: true } + ); + } + + async removeUnavailableDate(influencerId: string, date: Date) { + return AvailabilityModel.findOneAndUpdate( + { influencerId }, + { + $pull: { + overrides: { date }, + }, + }, + { new: true } + ); + } +} \ No newline at end of file diff --git a/apps/core-api/src/repositories/brand.repository.ts b/apps/core-api/src/repositories/brand.repository.ts new file mode 100644 index 0000000..2d27ee4 --- /dev/null +++ b/apps/core-api/src/repositories/brand.repository.ts @@ -0,0 +1,9 @@ +import { BrandProfile } from "../models/brand.model.js"; + +export const findBrandById = async (brandId: string) => { + return BrandProfile.findById(brandId).lean(); +}; + +export const findBrandByUserId = async (userId: string) => { + return BrandProfile.findOne({ userId }).lean(); +}; \ No newline at end of file diff --git a/apps/core-api/src/repositories/chat.repository.ts b/apps/core-api/src/repositories/chat.repository.ts new file mode 100644 index 0000000..5b7dc01 --- /dev/null +++ b/apps/core-api/src/repositories/chat.repository.ts @@ -0,0 +1,192 @@ +import { Types, type PipelineStage } from "mongoose"; + +import { MessageModel } from "../models/chat.model.js"; +import { GigRequestModel } from "../models/gig-request.model.js"; + +// Get messages by gigRequestId +export const findMessagesByGigRequest = async ( + gigRequestId: Types.ObjectId +) => { + return MessageModel.find({ + gigRequestId: new Types.ObjectId(gigRequestId), + }) + .sort({ createdAt: 1 }) + .lean(); +}; + +// Add new message +export const addMessage = async (data: { + gigRequestId: Types.ObjectId; + senderId: string; + receiverId: string; + content: string; + status: string; + messageType?: string; + proposalData?: Record; + deliverableData?: Record; +}) => { + return MessageModel.create(data); +}; + +// Get conversation list (sidebar) +export const aggregateConversations = async (userId: string, role?: string) => { + const userObjectId = new Types.ObjectId(userId); + + // Matches for pipeline match stage + const pipeline: PipelineStage[] = []; + + // Match active gig requests based on role + const matchRoles: Record[] = []; + if (role === "brand") { + matchRoles.push({ brandId: userObjectId }); + } else if (role === "influencer") { + matchRoles.push({ influencerId: userObjectId }); + } else { + matchRoles.push({ brandId: userObjectId }); + matchRoles.push({ influencerId: userObjectId }); + } + + pipeline.push({ + $match: { + $or: matchRoles + } + }); + + // Determine other user's ID + pipeline.push({ + $addFields: { + otherUserId: { + $cond: [ + { $eq: ["$brandId", userObjectId] }, + "$influencerId", + "$brandId", + ], + }, + }, + }); + + // Join User + pipeline.push({ + $lookup: { + from: "users", + localField: "otherUserId", + foreignField: "_id", + as: "user", + }, + }); + pipeline.push({ $unwind: "$user" }); + + // Look up profiles based on role + pipeline.push({ + $lookup: { + from: "influencerprofiles", + let: { uid: "$user._id" }, + pipeline: [{ $match: { $expr: { $eq: ["$userId", "$$uid"] } } }], + as: "influencerProfile", + }, + }); + pipeline.push({ + $lookup: { + from: "brandprofiles", + let: { uid: "$user._id" }, + pipeline: [{ $match: { $expr: { $eq: ["$userId", "$$uid"] } } }], + as: "brandProfile", + }, + }); + + // Resolve name and image + pipeline.push({ + $addFields: { + name: { + $cond: [ + { $eq: ["$user.role", "INFLUENCER"] }, + { $ifNull: [{ $arrayElemAt: ["$influencerProfile.fullName", 0] }, { $arrayElemAt: ["$influencerProfile.username", 0] }] }, + { $ifNull: [{ $arrayElemAt: ["$brandProfile.companyName", 0] }, { $arrayElemAt: ["$brandProfile.contactPersonName", 0] }] }, + ], + }, + profileImage: { + $cond: [ + { $eq: ["$user.role", "INFLUENCER"] }, + { $arrayElemAt: ["$influencerProfile.profileImageUrl", 0] }, + { $arrayElemAt: ["$brandProfile.profileImageUrl", 0] }, + ], + }, + }, + }); + + // Look up gig details + pipeline.push({ + $lookup: { + from: "gigs", + localField: "gigId", + foreignField: "_id", + as: "gig", + }, + }); + pipeline.push({ $unwind: { path: "$gig", preserveNullAndEmptyArrays: true } }); + + // Merge legacy "connectionId" field for messages since connection was renamed to gigRequest + pipeline.push({ + $lookup: { + from: "messages", + let: { reqId: "$_id" }, + pipeline: [ + { + $match: { + $expr: { + $or: [ + { $eq: ["$gigRequestId", "$$reqId"] }, + { $eq: ["$connectionId", "$$reqId"] } + ] + } + } + }, + { $sort: { createdAt: -1 } } + ], + as: "messages", + }, + }); + + // Parse out the last message and unread count + pipeline.push({ + $addFields: { + lastMessageDoc: { $arrayElemAt: ["$messages", 0] }, + unreadCount: { + $size: { + $filter: { + input: "$messages", + as: "msg", + cond: { + $and: [ + { $eq: ["$$msg.receiverId", userObjectId] }, + { $eq: ["$$msg.status", "SENT"] } + ] + } + } + } + } + } + }); + + // Final Output Formatting + pipeline.push({ + $project: { + _id: 1, + gigRequestId: "$_id", + lastMessage: { $ifNull: ["$lastMessageDoc.content", "No messages yet"] }, + lastMessageTime: { $ifNull: ["$lastMessageDoc.createdAt", "$updatedAt"] }, + unreadCount: 1, + gigTitle: { $ifNull: ["$gig.title", "Unknown Gig"] }, + user: { + _id: "$user._id", + name: { $ifNull: ["$name", "Unknown"] }, + role: "$user.role", + profileImage: "$profileImage", + }, + }, + }); + + pipeline.push({ $sort: { lastMessageTime: -1 } }); + + return GigRequestModel.aggregate(pipeline); +}; \ No newline at end of file diff --git a/apps/core-api/src/repositories/collaboration.repository.ts b/apps/core-api/src/repositories/collaboration.repository.ts new file mode 100644 index 0000000..1f6619f --- /dev/null +++ b/apps/core-api/src/repositories/collaboration.repository.ts @@ -0,0 +1,40 @@ +import { Types } from "mongoose"; + +import type { GigCollaborationDocument } from "../types/collaboration.type.js"; +import { GigCollaborationModel } from "../models/collaboration.model.js"; + + +export const createCollaboration = async ( + data: Omit +) => { + return GigCollaborationModel.create(data); +}; + +export const findPendingCollaborationsByGig = async ( + gigId: Types.ObjectId +) => { + return GigCollaborationModel.find({ + gigId, + status: "pending" + }); +}; + +export const findCollaborationById = async ( + id: Types.ObjectId +) => { + return GigCollaborationModel.findById(id); +}; + +export const updateCollaborationStatus = async ( + id: Types.ObjectId, + status: "accepted" | "rejected" +) => { + return GigCollaborationModel.findByIdAndUpdate( + id, + { + status, + respondedAt: new Date() + }, + { new: true } + ); +}; \ No newline at end of file diff --git a/apps/core-api/src/repositories/connection.repository.ts b/apps/core-api/src/repositories/connection.repository.ts new file mode 100644 index 0000000..31a6132 --- /dev/null +++ b/apps/core-api/src/repositories/connection.repository.ts @@ -0,0 +1,59 @@ +import { ConnectionModel } from "../models/connection.model.js"; + +export class ConnectionRepository { + // Create new connection + async create(data: { + brandId: string; + influencerId: string; + gigId?: string | undefined; + note?: string | undefined; + }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return ConnectionModel.create(data as any); + } + + // Find by ID + async findById(id: string) { + return ConnectionModel.findById(id); + } + + // Check existing connection (prevent duplicate) + async findExisting(brandId: string, influencerId: string, gigId?: string) { + const query: { brandId: string; influencerId: string; gigId?: string } = { brandId, influencerId }; + if (gigId) { + query.gigId = gigId; + } + return ConnectionModel.findOne(query); + } + + // Update status (accept/reject) + async updateStatus(id: string, status: string) { + return ConnectionModel.findByIdAndUpdate( + id, + { status }, + { new: true } + ); + } + + // Get all connections for user + async findMyConnections(userId: string, role?: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const filter: any = {}; + if (role === "brand") { + filter.brandId = userId; + } else if (role === "influencer") { + filter.influencerId = userId; + } else { + filter.$or = [ + { brandId: userId }, + { influencerId: userId }, + ]; + } + + return ConnectionModel.find(filter) + .populate("gigId") + .populate("brandId", "fullName profileImageUrl") + .populate("influencerId", "fullName profileImageUrl") + .sort({ createdAt: -1 }); + } +} \ No newline at end of file diff --git a/apps/core-api/src/repositories/gig-request.repository.ts b/apps/core-api/src/repositories/gig-request.repository.ts new file mode 100644 index 0000000..3573994 --- /dev/null +++ b/apps/core-api/src/repositories/gig-request.repository.ts @@ -0,0 +1,148 @@ +import { Types } from "mongoose"; + +import { GigRequestModel } from "../models/gig-request.model.js"; + +export class GigRequestRepository { + // Create new gig request + async create(data: { + brandId: string; + influencerId: string; + gigId: string; + note?: string | undefined; + }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return GigRequestModel.create(data as any); + } + + // Find by ID + async findById(id: string) { + const results = await GigRequestModel.aggregate([ + { $match: { _id: new Types.ObjectId(id) } }, + { + $lookup: { + from: "brandprofiles", + localField: "brandId", + foreignField: "userId", + as: "brandProfile", + }, + }, + { + $lookup: { + from: "influencerprofiles", + localField: "influencerId", + foreignField: "userId", + as: "influencerProfile", + }, + }, + { + $lookup: { + from: "gigs", + localField: "gigId", + foreignField: "_id", + as: "gigData", + }, + }, + { + $project: { + _id: 1, + brandId: 1, + influencerId: 1, + gigId: { $arrayElemAt: ["$gigData", 0] }, + status: 1, + note: 1, + brandProfile: { $arrayElemAt: ["$brandProfile", 0] }, + influencerProfile: { $arrayElemAt: ["$influencerProfile", 0] }, + } + } + ]); + return results[0] || null; + } + + // Check existing request (prevent duplicate per gig) + async findExisting(brandId: string, influencerId: string, gigId: string) { + return GigRequestModel.findOne({ brandId, influencerId, gigId }); + } + + // Update status (accept/reject) + async updateStatus(id: string, status: "accepted" | "rejected") { + return GigRequestModel.findByIdAndUpdate( + id, + { status }, + { new: true } + ); + } + + // Get all requests for a user (brand or influencer) + async findMyRequests(userId: string) { + return GigRequestModel.aggregate([ + { + $match: { + $or: [ + { brandId: new Types.ObjectId(userId) }, + { influencerId: new Types.ObjectId(userId) }, + ], + }, + }, + { + $lookup: { + from: "users", + localField: "brandId", + foreignField: "_id", + as: "brandUser", + }, + }, + { + $lookup: { + from: "brandprofiles", + localField: "brandId", + foreignField: "userId", + as: "brandProfile", + }, + }, + { + $lookup: { + from: "users", + localField: "influencerId", + foreignField: "_id", + as: "influencerUser", + }, + }, + { + $lookup: { + from: "influencerprofiles", + localField: "influencerId", + foreignField: "userId", + as: "influencerProfile", + }, + }, + { + $lookup: { + from: "gigs", + localField: "gigId", + foreignField: "_id", + as: "gigData", + }, + }, + { + $project: { + _id: 1, + status: 1, + note: 1, + createdAt: 1, + brandId: { + _id: { $arrayElemAt: ["$brandUser._id", 0] }, + fullName: { $arrayElemAt: ["$brandProfile.companyName", 0] }, + profileImageUrl: { $arrayElemAt: ["$brandProfile.profileImageUrl", 0] }, + }, + influencerId: { + _id: { $arrayElemAt: ["$influencerUser._id", 0] }, + fullName: { $arrayElemAt: ["$influencerProfile.fullName", 0] }, + profileImageUrl: { $arrayElemAt: ["$influencerProfile.profileImageUrl", 0] }, + }, + gigId: { $arrayElemAt: ["$gigData", 0] }, + }, + }, + { $sort: { createdAt: -1 } }, + ]); + } +} diff --git a/apps/core-api/src/repositories/gig.repository.ts b/apps/core-api/src/repositories/gig.repository.ts new file mode 100644 index 0000000..ad48750 --- /dev/null +++ b/apps/core-api/src/repositories/gig.repository.ts @@ -0,0 +1,119 @@ +import type { SortOrder, Types } from "mongoose"; + +import { GigModel } from "../models/gig.model.js"; +import type { CreateGigDBInput, GigDocument } from "../types/gig.type.js"; + +export const create_gig = async ( + data: CreateGigDBInput +) => { + return GigModel.create(data); +}; + +/* ================= FGET ALL GIGS ================= */ +export const getAllGigs = async () => { + return GigModel.find(); +} + +/* ================= FIND BY ID ================= */ + +export const findGigById = async ( + gigId: string | Types.ObjectId +) => { + return GigModel.findById(gigId); +}; + +/* ================= UPDATE ================= */ + +export const updateGigById = async ( + gigId: string | Types.ObjectId, + updateData: Partial +) => { + return GigModel.findByIdAndUpdate( + gigId, + updateData, + { new: true } + ); +}; + +/* ================= SOFT DELETE ================= */ + +export const softDeleteGig = async ( + gigId: string | Types.ObjectId +) => { + return GigModel.findByIdAndUpdate( + gigId, + { isDeleted: true }, + { new: true } + ); +}; +export const findPublishedGigs = async ( + filter: Partial, + sort: Record, + skip: number, + limit: number +) => { + const [gigs, total] = await Promise.all([ + GigModel.find(filter) + .select( + "title shortDescription category pricing.basePrice pricing.currency primaryInfluencerId createdAt bannerUrl influencer" + ) + .populate("primaryInfluencerId", "userId fullName profileImageUrl categories") + .sort(sort) + .skip(skip) + .limit(limit) + .lean(), + GigModel.countDocuments(filter) + ]); + + return { gigs, total }; +}; +// export const findPublishedGigs = async ( +// filter: any, +// sort: any, +// skip: number, +// limit: number +// ) => { +// const [gigs, total] = await Promise.all([ +// GigModel.find(filter) +// .select( +// "title category pricing.basePrice pricing.currency primaryInfluencerId createdAt" +// ) +// .sort(sort) +// .skip(skip) +// .limit(limit) +// .lean(), +// GigModel.countDocuments(filter) +// ]); + +// return { gigs, total }; +// }; +export const findPublishedGigById = async (gigId: string) => { + return GigModel.findOne({ + _id: gigId, + status: "published", + isDeleted: false + }) + .populate({ + path: "primaryInfluencerId", + select: "userId fullName profileImageUrl followersCount categories" + }) + .lean(); +}; + +export const findActiveGigById = async ( + gigId: string | Types.ObjectId +) => { + return GigModel.findOne({ + _id: gigId, + isDeleted: false + }); +}; + +export const findGigsByInfluencer = async ( + influencerProfileId: string | Types.ObjectId +) => { + return GigModel.find({ + primaryInfluencerId: influencerProfileId, + isDeleted: false + }).sort({ createdAt: -1 }); +}; \ No newline at end of file diff --git a/apps/core-api/src/repositories/profile.repository.ts b/apps/core-api/src/repositories/profile.repository.ts new file mode 100644 index 0000000..de2d44a --- /dev/null +++ b/apps/core-api/src/repositories/profile.repository.ts @@ -0,0 +1,55 @@ +import { BrandProfile } from "../models/brand.model.js"; +import type { IBrandProfile } from "../models/brand.model.js" +import { InfluencerProfile} from "../models/influencer.model.js"; +import type { IInfluencerProfile } from "../models/influencer.model.js" + +export class ProfileRepository { + + async findInfluencerByUserId(userId: string): Promise { + return InfluencerProfile.findOne({ userId }); + } + + async findInfluencerById(id: string): Promise { + return InfluencerProfile.findById(id); + } + + async findBrandByUserId(userId: string): Promise { + return BrandProfile.findOne({ userId }); + } + + async createInfluencer( + data: Partial + ): Promise { + return InfluencerProfile.create(data); + } + + async createBrand( + data: Partial + ): Promise { + return BrandProfile.create(data); + } + + async updateInfluencer( + userId: string, + data: Partial + ): Promise { + return InfluencerProfile.findOneAndUpdate( + { userId }, + data, + { new: true } + ); + } + + async updateBrand( + userId: string, + data: Partial + ): Promise { + return BrandProfile.findOneAndUpdate( + { userId }, + data, + { new: true } + ); + } +} + +export const profileRepository = new ProfileRepository(); diff --git a/apps/core-api/src/repositories/report.repository.ts b/apps/core-api/src/repositories/report.repository.ts new file mode 100644 index 0000000..72ea5c9 --- /dev/null +++ b/apps/core-api/src/repositories/report.repository.ts @@ -0,0 +1,12 @@ +import type { QueryFilter } from "mongoose"; + +import { ReportModel } from "../models/report.model.js"; +import type { ReportDocument } from "../types/report.types.js"; + +export const createReportRepository = async (payload: Partial) => { + return await ReportModel.create(payload); +}; + +export const findOneReportRepository = async (query: QueryFilter) => { + return await ReportModel.findOne(query); +}; \ No newline at end of file diff --git a/apps/core-api/src/repositories/user.repository.ts b/apps/core-api/src/repositories/user.repository.ts index 9e0ef02..2963672 100644 --- a/apps/core-api/src/repositories/user.repository.ts +++ b/apps/core-api/src/repositories/user.repository.ts @@ -1,14 +1,139 @@ -import { User } from "../modules/users/user.model.js"; +import { User } from "../models/user.model.js"; + +class UserRepository { + + //FIND ALL USERS + + async findAllUsers() { + return User.find(); + } + + + + + + // FIND USER WITH PASSWORD + + async findEmailWithPassword(email: string) { + const normalizedEmail = email.trim().toLowerCase(); + return User.findOne({ email: normalizedEmail }).select("+password"); + } + + + // FIND USER WITH RESET FIELDS + + async findByEmailWithResetFields(email: string) { + const normalizedEmail = email.trim().toLowerCase(); + + return User.findOne({ email: normalizedEmail }) + .select("+password +resetOtp +resetOtpExpiry +resetSessionExpiry +resetSessionToken"); + } + + + // NORMAL FIND BY EMAIL + + async findByEmail(email: string) { + const normalizedEmail = email.trim().toLowerCase(); + return User.findOne({ email: normalizedEmail }); + } + + // FIND BY ID + + async findById(userId: string) { + return User.findById(userId).select("+refreshToken"); + } + + + // SAVE REFRESH TOKEN + + async saveRefreshToken(userId: string, refreshToken: string) { + + return User.findByIdAndUpdate( + userId, + { refreshToken }, + { new: true } + ); + } + + + // SAVE RESET OTP + + async saveResetOtp( + userId: string, + hashedOtp: string, + expiry: Date + ) { + return User.findByIdAndUpdate( + userId, + { + resetOtp: hashedOtp, + resetOtpExpiry: expiry, + }, + { new: true } + ); + } + + + // SAVE RESET SESSION TOKEN + + async saveResetSession( + userId: string, + token: string, + expiry: Date + ) { + return User.findByIdAndUpdate( + userId, + { + $unset: { resetOtp: 1, resetOtpExpiry: 1 }, + $set: { resetSessionToken: token, resetSessionExpiry: expiry }, + }, + { new: true } + ); + } + + + // UPDATE PASSWORD (Production-Safe) + + async updatePassword(userId: string, hashedPassword: string) { + return User.findByIdAndUpdate( + userId, + { + password: hashedPassword, + refreshToken: "", // invalidate all sessions + }, + { + new: true, + runValidators: true, + } + ); + } + + + // CLEAR RESET SESSION + + async clearResetSession(userId: string) { + return User.findByIdAndUpdate( + userId, + { + $unset: { resetSessionToken: 1, resetSessionExpiry: 1 } + }, + { new: true } + ); + } -class UserRepository{ - async findEmailWithPassword(email: string) { - return User.findOne({ email }).select("+password"); -} + // CREATE USER - async saveRefreshToken(userId:string, refreshToken:string){ - return User.findByIdAndUpdate(userId,{refreshToken}) - } + async create(data: { + email: string; + password: string; + role: string; + adminLevel?: string; + isEmailVerified: boolean; + status: string; + }) { + return User.create(data); + } } -export const userRepository = new UserRepository() \ No newline at end of file +export const userRepository = new UserRepository(); diff --git a/apps/core-api/src/routes/admin.route.ts b/apps/core-api/src/routes/admin.route.ts new file mode 100644 index 0000000..0015933 --- /dev/null +++ b/apps/core-api/src/routes/admin.route.ts @@ -0,0 +1,48 @@ +import { Router } from "express"; + +import { authenticate } from "../middlewares/auth.middleware.js"; +import { + updateReportStatusController, + resolveReportController, + getReportsController, + getReportByIdController +} from "../controllers/report.controller.js"; +import { + approveSignupController, + getAllpendingSignupController, + rejectSignupController, + getTotalUsersController, + getTotalGigsController, + getRecentActivityController, + getGigModerationStatsController, + pauseGigController, + ignoreGigController, + rejectGigController, + getPlatformRevenueController, + getBookingsAuditController +} from "../controllers/admin.controller.js"; + +const router: Router = Router(); + +router.get("/revenue", authenticate, getPlatformRevenueController); +router.get("/bookings", authenticate, getBookingsAuditController); + +router.get("/signup", getAllpendingSignupController); +router.post("/signup/approve", authenticate, approveSignupController); +router.post("/signup/reject", authenticate, rejectSignupController); +router.get("/total-users", authenticate, getTotalUsersController); +router.get("/total-gigs", authenticate, getTotalGigsController); +router.get("/recent-activity", authenticate, getRecentActivityController); +router.get("/gig-stats", authenticate, getGigModerationStatsController); +router.post("/gigs/:gigId/pause", authenticate, pauseGigController); +router.post("/gigs/:gigId/ignore", authenticate, ignoreGigController); +router.post("/gigs/:gigId/reject", authenticate, rejectGigController); + +// Report Moderation +router.get("/reports", authenticate, getReportsController); +router.get("/reports/:id", authenticate, getReportByIdController); +router.patch("/reports/:id/status", authenticate, updateReportStatusController); +router.patch("/reports/:id/resolve", authenticate, resolveReportController); + + +export default router; \ No newline at end of file diff --git a/apps/core-api/src/routes/auth.routes.ts b/apps/core-api/src/routes/auth.routes.ts index 109b7d8..32dca18 100644 --- a/apps/core-api/src/routes/auth.routes.ts +++ b/apps/core-api/src/routes/auth.routes.ts @@ -1,7 +1,19 @@ -import { Router } from "express"; +import { Router } from "express"; -import { loginController } from "../controllers/auth.controller.js"; +import { forgotPasswordController, loginController, logoutController, pendingProfileController, refreshTokenController, resendSignupOtpController, resetPasswordController, signupController, verifyResetOtpController, verifySignupOtpController } from "../controllers/auth.controller.js"; +import { authenticate } from "../middlewares/auth.middleware.js"; const router: Router = Router() router.post("/login", loginController) -export default router \ No newline at end of file +router.post("/signup", signupController); +router.post("/refresh", refreshTokenController); +router.post("/logout", authenticate, logoutController); +router.post("/verify-signup-otp", verifySignupOtpController); +router.post("/resend-signup-otp", resendSignupOtpController); +router.post("/forgot-password", forgotPasswordController); +router.post("/verify-reset-otp", verifyResetOtpController); +router.post("/reset-password", resetPasswordController); +router.post("/pending-profile", pendingProfileController); + + +export default router diff --git a/apps/core-api/src/routes/availability.routes.ts b/apps/core-api/src/routes/availability.routes.ts index 083869f..6d861bc 100644 --- a/apps/core-api/src/routes/availability.routes.ts +++ b/apps/core-api/src/routes/availability.routes.ts @@ -1,5 +1,42 @@ -import { Router } from "express" +// import { Router } from "express"; -const router : Router = Router() +// import { authenticate, authorizePermission } from "../middlewares/auth.middleware.js"; +// import { Permission } from "../rbac/permission.js"; +// import { getAvailabilityController, setAvailabilityController } from "../controllers/availability.controller.js"; -export default router \ No newline at end of file +// const router: Router = Router(); + + +// router.post( +// "/", +// authenticate, +// authorizePermission(Permission.UPDATE_PROFILE), +// setAvailabilityController +// ); + + +// router.get( +// "/:influencerProfileId", +// getAvailabilityController +// ); + +// export default router; + +import { Router } from "express"; + +import { AvailabilityController } from "../controllers/availability.controller.js"; +import { authenticate } from "../middlewares/auth.middleware.js"; + +const router: Router = Router(); +const controller = new AvailabilityController(); + +// Add leave +router.post("/unavailable", authenticate, controller.addUnavailable); + +// Remove leave +router.delete("/unavailable", authenticate, controller.removeUnavailable); + +// Check today +router.get("/check/:influencerId", controller.checkToday); + +export default router; \ No newline at end of file diff --git a/apps/core-api/src/routes/brand.route.ts b/apps/core-api/src/routes/brand.route.ts new file mode 100644 index 0000000..f89ccaa --- /dev/null +++ b/apps/core-api/src/routes/brand.route.ts @@ -0,0 +1,9 @@ +import { Router } from "express"; + +import { getPublicBrandProfileController } from "../controllers/brand.controller.js"; + +const router: Router = Router(); + +router.get("/:brandId", getPublicBrandProfileController); + +export default router; \ No newline at end of file diff --git a/apps/core-api/src/routes/chat.routes.ts b/apps/core-api/src/routes/chat.routes.ts new file mode 100644 index 0000000..8287662 --- /dev/null +++ b/apps/core-api/src/routes/chat.routes.ts @@ -0,0 +1,50 @@ +import { Router } from "express"; + +import { + getConversationsController, + getChatMessagesController, + markAsReadController, + sendMessageController, + respondToProposalController, + getAgreedProposalsController, + submitDeliverableController, + respondToDeliverableController +} from "../controllers/chat.controller.js"; +import { authenticate } from "../middlewares/auth.middleware.js"; + +const router: Router = Router(); +router.get("/conversations", authenticate, getConversationsController); +router.get("/agreed-proposals", authenticate, getAgreedProposalsController); +router.post("/send", authenticate, sendMessageController); + +router.get( + "/:gigRequestId", + authenticate, + getChatMessagesController +); + +router.post( + "/read/:gigRequestId", + authenticate, + markAsReadController +); + +router.patch( + "/proposal-respond/:messageId", + authenticate, + respondToProposalController +); + +router.post( + "/submit-deliverable", + authenticate, + submitDeliverableController +); + +router.patch( + "/deliverable-respond/:messageId", + authenticate, + respondToDeliverableController +); + +export default router; \ No newline at end of file diff --git a/apps/core-api/src/routes/collaboration.route.ts b/apps/core-api/src/routes/collaboration.route.ts new file mode 100644 index 0000000..ddbf7bc --- /dev/null +++ b/apps/core-api/src/routes/collaboration.route.ts @@ -0,0 +1,27 @@ +import { Router } from "express"; + +import { inviteCollaboratorsController, respondToCollaborationController } from "../controllers/collaboration.controllers.js"; +import { authenticate } from "../middlewares/auth.middleware.js"; + + +const router:Router = Router(); + +/** + * Invite collaborators to a gig + */ +router.post( + "/gigs/:id/collaborators", + authenticate, + inviteCollaboratorsController +); + +/** + * Accept or reject collaboration + */ +router.patch( + "/:id/respond", + authenticate, + respondToCollaborationController +); + +export default router; \ No newline at end of file diff --git a/apps/core-api/src/routes/connection.route.ts b/apps/core-api/src/routes/connection.route.ts new file mode 100644 index 0000000..06c5733 --- /dev/null +++ b/apps/core-api/src/routes/connection.route.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; + +import { ConnectionController } from "../controllers/connection.controller.js"; +import { authenticate } from "../middlewares/auth.middleware.js"; + +const router: Router = Router(); +const controller = new ConnectionController(); + +router.post("/request", authenticate, controller.sendRequest); +router.patch("/:id/accept", authenticate, controller.accept); +router.patch("/:id/reject", authenticate, controller.reject); +router.get("/my", authenticate, controller.myConnections); +router.get("/details/:id", authenticate, controller.getById); +router.get("/:receiverId", authenticate, controller.getConnectionWithReceiver); + +export default router; \ No newline at end of file diff --git a/apps/core-api/src/routes/gig-request.route.ts b/apps/core-api/src/routes/gig-request.route.ts new file mode 100644 index 0000000..2dcd41f --- /dev/null +++ b/apps/core-api/src/routes/gig-request.route.ts @@ -0,0 +1,20 @@ +import { Router } from "express"; + +import { GigRequestController } from "../controllers/gig-request.controller.js"; +import { authenticate } from "../middlewares/auth.middleware.js"; + +const router: Router = Router(); +const controller = new GigRequestController(); + +// Brand sends a gig request +router.post("/request", authenticate, controller.sendRequest.bind(controller)); +// Influencer accepts +router.patch("/:id/accept", authenticate, controller.accept.bind(controller)); +// Influencer rejects +router.patch("/:id/reject", authenticate, controller.reject.bind(controller)); +// Get all my requests (brand sees outgoing, influencer sees incoming) +router.get("/my", authenticate, controller.myRequests.bind(controller)); +// Get a single request by ID +router.get("/details/:id", authenticate, controller.getById.bind(controller)); + +export default router; diff --git a/apps/core-api/src/routes/gigs.routes.ts b/apps/core-api/src/routes/gigs.routes.ts index 9bf7f97..a1e2718 100644 --- a/apps/core-api/src/routes/gigs.routes.ts +++ b/apps/core-api/src/routes/gigs.routes.ts @@ -1,5 +1,32 @@ import { Router } from "express"; +import { authenticate, authorizePermission } from "../middlewares/auth.middleware.js"; +import { Permission } from "../rbac/permission.js"; +import { createGigController, deleteGigController, editGigController, getGigDetailsController, listGigsController, publishGigController, updateGigDeliverablesController, updateGigPricingController } from "../controllers/gig.controller.js"; + + + const router: Router = Router() + +router.get("/", listGigsController); +router.post("/create_gig",authenticate,authorizePermission(Permission.CREATE_GIG),createGigController); +router.patch( + "/:id/deliverables", authenticate, authorizePermission(Permission.UPDATE_GIG), updateGigDeliverablesController); +router.patch( + "/:id/pricing", + authenticate, + authorizePermission(Permission.UPDATE_GIG), + updateGigPricingController +); +router.post( + "/:id/publish", + authenticate, + authorizePermission(Permission.PUBLISH_GIG), + publishGigController +); +router.patch("/:id",authenticate,authorizePermission(Permission.UPDATE_GIG),editGigController); +router.delete("/:id",authenticate,authorizePermission(Permission.DELETE_GIG),deleteGigController); +router.get("/:id", getGigDetailsController); + export default router \ No newline at end of file diff --git a/apps/core-api/src/routes/index.ts b/apps/core-api/src/routes/index.ts index aa21217..6c4b9c1 100644 --- a/apps/core-api/src/routes/index.ts +++ b/apps/core-api/src/routes/index.ts @@ -1,5 +1,6 @@ import { Router } from "express"; +import mediaRoutes from "./media.routes.js"; import userRoutes from "./users.routes.js"; import authRoutes from "./auth.routes.js"; import profileRoutes from "./profile.routes.js"; @@ -8,12 +9,21 @@ import availabilityRoutes from "./availability.routes.js"; import bookingRoutes from "./bookings.routes.js"; import orderRoutes from "./orders.routes.js"; import paymentRoutes from "./payments.routes.js"; +import payoutRoutes from "./payout.routes.js"; import searchRoutes from "./search.routes.js"; import healthRoutes from "./health.routes.js"; +import adminRoutes from "./admin.route.js" +import chatRoutes from "./chat.routes.js" +import brandRoutes from "./brand.route.js"; +import reportRoutes from "./report.route.js"; +import connectionRoutes from "./gig-request.route.js"; +import notificationRoutes from "./notification.routes.js"; -const router:Router = Router() + +const router: Router = Router() router.use("/health", healthRoutes) router.use("/auth", authRoutes) +router.use("/admin", adminRoutes) router.use("/users", userRoutes) router.use("/profile", profileRoutes) router.use("/gigs", gigRoutes); @@ -21,6 +31,14 @@ router.use("/availability", availabilityRoutes); router.use("/bookings", bookingRoutes); router.use("/orders", orderRoutes); router.use("/payments", paymentRoutes); +router.use("/payouts", payoutRoutes); router.use("/search", searchRoutes); +router.use("/media", mediaRoutes); +router.use("/chat", chatRoutes); +router.use("/brands", brandRoutes); +router.use("/reports", reportRoutes); +router.use("/connections", connectionRoutes); +router.use("/notifications", notificationRoutes); + export default router \ No newline at end of file diff --git a/apps/core-api/src/routes/media.routes.ts b/apps/core-api/src/routes/media.routes.ts new file mode 100644 index 0000000..b81c5a3 --- /dev/null +++ b/apps/core-api/src/routes/media.routes.ts @@ -0,0 +1,12 @@ +import { Router } from "express"; + +import { generateUploadUrlController } from "../controllers/media.controller.js"; + +const router: Router = Router(); + +router.post( + "/upload-url", + generateUploadUrlController +); + +export default router; diff --git a/apps/core-api/src/routes/notification.routes.ts b/apps/core-api/src/routes/notification.routes.ts new file mode 100644 index 0000000..362ec40 --- /dev/null +++ b/apps/core-api/src/routes/notification.routes.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; + +import { NotificationController } from "../controllers/notification.controller.js"; +import { authenticate } from "../middlewares/auth.middleware.js"; + +const router: Router = Router(); +const controller = new NotificationController(); + +router.post("/subscriptions", authenticate, controller.saveSubscription.bind(controller)); +router.delete("/subscriptions", authenticate, controller.removeSubscription.bind(controller)); +router.get("/my", authenticate, controller.getMyNotifications.bind(controller)); +router.patch("/:id/read", authenticate, controller.markAsRead.bind(controller)); +router.patch("/read-all", authenticate, controller.markAllAsRead.bind(controller)); + +export default router; diff --git a/apps/core-api/src/routes/orders.routes.ts b/apps/core-api/src/routes/orders.routes.ts index 9bf7f97..771d148 100644 --- a/apps/core-api/src/routes/orders.routes.ts +++ b/apps/core-api/src/routes/orders.routes.ts @@ -1,5 +1,19 @@ -import { Router } from "express"; - -const router: Router = Router() - -export default router \ No newline at end of file +import { Router } from "express"; + +import { approveWork, markCompleted, rejectWork, releasePayment, getOrderDetails, createOrder, getHistory, getOrderBySession, cancelOrder } from "../controllers/order.controller.js"; +import { authenticate } from "../middlewares/auth.middleware.js"; + +const router: Router = Router(); + +router.post("/", authenticate, createOrder); +router.get("/history", authenticate, getHistory); +router.get("/by-session/:sessionId", authenticate, getOrderBySession); +router.get("/details/:id", authenticate, getOrderDetails); + +router.patch("/release/:id", authenticate, releasePayment); +router.patch("/submit/:id", authenticate, markCompleted); +router.patch("/approve/:id", authenticate, approveWork); +router.patch("/reject/:id", authenticate, rejectWork); +router.patch("/cancel/:id", authenticate, cancelOrder); + +export default router; \ No newline at end of file diff --git a/apps/core-api/src/routes/payments.routes.ts b/apps/core-api/src/routes/payments.routes.ts index ee617b7..b226b62 100644 --- a/apps/core-api/src/routes/payments.routes.ts +++ b/apps/core-api/src/routes/payments.routes.ts @@ -1,5 +1,16 @@ import { Router } from "express"; -const router : Router = Router() +import { + createCheckout, + stripeWebhook, + createStripeAccountLink, +} from "../controllers/payment.controller.js"; +import { authenticate } from "../middlewares/auth.middleware.js"; -export default router \ No newline at end of file +const router: Router = Router(); + +router.post("/checkout", createCheckout); +router.post("/connect", authenticate, createStripeAccountLink); +router.post("/webhook", stripeWebhook); + +export default router; \ No newline at end of file diff --git a/apps/core-api/src/routes/payout.routes.ts b/apps/core-api/src/routes/payout.routes.ts new file mode 100644 index 0000000..0415fa4 --- /dev/null +++ b/apps/core-api/src/routes/payout.routes.ts @@ -0,0 +1,11 @@ +import { Router } from "express"; + +import { withdrawBalance } from "../controllers/payout.controller.js"; +import { authenticate } from "../middlewares/auth.middleware.js"; + +const router: Router = Router(); + +// 🔥 Withdraw FULL available balance +router.post("/withdraw", authenticate, withdrawBalance); + +export default router; diff --git a/apps/core-api/src/routes/profile.routes.ts b/apps/core-api/src/routes/profile.routes.ts index ee617b7..266e461 100644 --- a/apps/core-api/src/routes/profile.routes.ts +++ b/apps/core-api/src/routes/profile.routes.ts @@ -1,5 +1,17 @@ -import { Router } from "express"; - -const router : Router = Router() - -export default router \ No newline at end of file +import { Router } from "express"; + +import { authenticate } from "../middlewares/auth.middleware.js"; +import { + getMyProfileController, + updateProfileController, + getPublicInfluencerProfileController +} from "../controllers/profile.controller.js"; + +const router: Router = Router(); + +router.get("/get_profile", authenticate, getMyProfileController); +router.patch("/update_profile", authenticate, updateProfileController); +router.get("/influencer/:id", getPublicInfluencerProfileController); + + +export default router; diff --git a/apps/core-api/src/routes/report.route.ts b/apps/core-api/src/routes/report.route.ts new file mode 100644 index 0000000..cbf3384 --- /dev/null +++ b/apps/core-api/src/routes/report.route.ts @@ -0,0 +1,11 @@ +import { Router } from "express"; + +import { createReportController } from "../controllers/report.controller.js"; +import { authenticate } from "../middlewares/auth.middleware.js"; + +const router: Router = Router(); + +router.post("/", authenticate, createReportController); + + +export default router; \ No newline at end of file diff --git a/apps/core-api/src/routes/search.routes.ts b/apps/core-api/src/routes/search.routes.ts index 9bf7f97..3c20a7a 100644 --- a/apps/core-api/src/routes/search.routes.ts +++ b/apps/core-api/src/routes/search.routes.ts @@ -1,5 +1,13 @@ import { Router } from "express"; -const router: Router = Router() +import { searchBrands, searchGigs, searchInfluencers } from "../controllers/search.controller.js"; + +const router: Router = Router(); + +router.get("/gigs",searchGigs); +router.get("/influencers", searchInfluencers); +router.get("/brands", searchBrands); + + export default router \ No newline at end of file diff --git a/apps/core-api/src/routes/users.routes.ts b/apps/core-api/src/routes/users.routes.ts index 9bf7f97..23abbe1 100644 --- a/apps/core-api/src/routes/users.routes.ts +++ b/apps/core-api/src/routes/users.routes.ts @@ -1,5 +1,10 @@ -import { Router } from "express"; +import { Router } from "express"; -const router: Router = Router() +import { getDashboardCounts } from "../controllers/user.controller.js"; +import { authenticate } from "../middlewares/auth.middleware.js"; -export default router \ No newline at end of file +const router: Router = Router(); + +router.get("/counts", authenticate, getDashboardCounts); + +export default router; \ No newline at end of file diff --git a/apps/core-api/src/search/filters/brand.filter.ts b/apps/core-api/src/search/filters/brand.filter.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/core-api/src/search/filters/gig.filter.ts b/apps/core-api/src/search/filters/gig.filter.ts new file mode 100644 index 0000000..0333e3e --- /dev/null +++ b/apps/core-api/src/search/filters/gig.filter.ts @@ -0,0 +1,17 @@ +export const buildGigFilters = (query: {niche?: string; minPrice?: number; maxPrice?: number}): string[] => { + const filters: string[] = []; + + if (query.niche) { + filters.push(`niche = "${query.niche}"`); + } + + if (query.minPrice) { + filters.push(`price >= ${Number(query.minPrice)}`); + } + + if (query.maxPrice) { + filters.push(`price <= ${Number(query.maxPrice)}`); + } + + return filters; +}; \ No newline at end of file diff --git a/apps/core-api/src/search/filters/influencer.filter.ts b/apps/core-api/src/search/filters/influencer.filter.ts new file mode 100644 index 0000000..f20ec7a --- /dev/null +++ b/apps/core-api/src/search/filters/influencer.filter.ts @@ -0,0 +1,21 @@ +export const buildInfluencerFilters = (query: { + niche?: string; + minFollowers?: number; + maxFollowers?: number; +}) => { + const filters: string[] = []; + + if (query.niche) { + filters.push(`niche = "${query.niche}"`); + } + + if (query.minFollowers) { + filters.push(`followers >= ${Number(query.minFollowers)}`); + } + + if (query.maxFollowers) { + filters.push(`followers <= ${Number(query.maxFollowers)}`); + } + + return filters; +}; \ No newline at end of file diff --git a/apps/core-api/src/search/indexes/brand.index.ts b/apps/core-api/src/search/indexes/brand.index.ts new file mode 100644 index 0000000..709257a --- /dev/null +++ b/apps/core-api/src/search/indexes/brand.index.ts @@ -0,0 +1,10 @@ +import { meili } from "../meili.js"; + +export const setupBrandIndex=async ()=>{ + const index=meili.index("brands"); + + await index.updateSettings({ + searchableAttributes:["companyName","industry"], + filterableAttributes:["industry"] + }) +} \ No newline at end of file diff --git a/apps/core-api/src/search/indexes/gig.index.ts b/apps/core-api/src/search/indexes/gig.index.ts new file mode 100644 index 0000000..d9a0217 --- /dev/null +++ b/apps/core-api/src/search/indexes/gig.index.ts @@ -0,0 +1,10 @@ +import { meili } from "../meili.js"; + +export const setupGigIndex=async () =>{ + const index=meili.index("gigs"); + + await index.updateSettings({ + searchableAttributes:["title","category","tags","price","searchMeta"], + filterableAttributes:["category","price","time"] + }) +} \ No newline at end of file diff --git a/apps/core-api/src/search/indexes/influencer.index.ts b/apps/core-api/src/search/indexes/influencer.index.ts new file mode 100644 index 0000000..37feff1 --- /dev/null +++ b/apps/core-api/src/search/indexes/influencer.index.ts @@ -0,0 +1,27 @@ +import { meili } from "../meili.js"; + +export const setupInfluencerIndex = async () => { + const index = meili.index("influencers"); + + await index.updateSettings({ + searchableAttributes: [ + "fullName", + "username", + "instagram", + "youtube", + "category", + "location", + "languages", + "followersCount", + "engagementRate" + ], + filterableAttributes: [ + "category", + "languages", + "followersCount", + "engagementRate" + ] + }); + + console.log("Influencer index configured"); +}; \ No newline at end of file diff --git a/apps/core-api/src/search/meili.ts b/apps/core-api/src/search/meili.ts index c6a2664..27bf46c 100644 --- a/apps/core-api/src/search/meili.ts +++ b/apps/core-api/src/search/meili.ts @@ -17,3 +17,5 @@ if (process.env.MEILI_KEY) { export const meili = new MeiliSearch(config); logger.info("Meilisearch client initialized"); + + diff --git a/apps/core-api/src/search/services/search.service.ts b/apps/core-api/src/search/services/search.service.ts new file mode 100644 index 0000000..4cd0c01 --- /dev/null +++ b/apps/core-api/src/search/services/search.service.ts @@ -0,0 +1,11 @@ +import { meili } from "../meili.js"; + +export const searchIndex = async ( + indexName: string, + query: string, + options: { limit?: number; offset?: number; filters?: string[] } +) => { + const index = meili.index(indexName); + return await index.search(query || "", options); +}; + \ No newline at end of file diff --git a/apps/core-api/src/search/setup.ts b/apps/core-api/src/search/setup.ts new file mode 100644 index 0000000..143b3fa --- /dev/null +++ b/apps/core-api/src/search/setup.ts @@ -0,0 +1,20 @@ +import { logger } from "../utils/logger.js"; + +import { setupBrandIndex } from "./indexes/brand.index.js"; +import { setupGigIndex } from "./indexes/gig.index.js"; +import { setupInfluencerIndex } from "./indexes/influencer.index.js"; + +export const setupMeili=async ()=>{ + try { + await Promise.all([ + setupBrandIndex(), + setupGigIndex(), + setupInfluencerIndex() + ]) + logger.info("Meilisearch indexes configured successfully"); + + } catch (error) { + logger.error("Meili setup failed",error) + // process.exit(1); // prevent server from crashing during local dev if Meili is down + } +} \ No newline at end of file diff --git a/apps/core-api/src/server.ts b/apps/core-api/src/server.ts index 75f7cf0..c72fd4c 100644 --- a/apps/core-api/src/server.ts +++ b/apps/core-api/src/server.ts @@ -1,34 +1,69 @@ -import "dotenv/config" -import express from "express" - -import { httpLogger } from "./middlewares/httpLogger.js" -import { logger } from "./utils/logger.js" -import { errorHandler, notFound } from "./middlewares/errorHandler.js" -import { connectRabbit } from "./queue/rabbit.js" -import "./cache/redis.js"; -import "./search/meili.js"; -import router from "./routes/index.js" -import { connectDB } from "./db/connect.js" +import cors from "cors" +import "dotenv/config"; +import express from "express"; +import cookieParser from "cookie-parser"; +import cron from "node-cron"; + +import { httpLogger } from "./middlewares/httpLogger.js"; +import { logger } from "./utils/logger.js"; +import { errorHandler, notFound } from "./middlewares/errorHandler.js"; +import { connectRabbit } from "./queue/rabbit.js"; +import "./cache/redis.js"; +import "./search/meili.js"; +import router from "./routes/index.js"; +import { connectDB } from "./db/connect.js"; +import { cleanupExpiredSignups } from "./services/verification.service.js"; +import { setupMeili } from "./search/setup.js"; +import { GIG_CREATED_EVENT } from "./controllers/gig.controller.js"; +import { GIG_REQUEST_CREATED_EVENT } from "./queue/events.js"; +import { getChannel } from "./queue/rabbit.js"; const app = express() const PORT = Number(process.env.PORT) || 5000 app.use(httpLogger) +app.use(cookieParser()); +app.use(cors({ + origin: process.env.FRONTEND_URL || "http://localhost:3000", + credentials: true, +})) + +app.use(httpLogger); app.use(express.json()) -app.use("/api", router) -connectDB() +app.use("/api", router); +app.use( + "/payments/webhook", + express.raw({ type: "application/json" }) +); + +// Connect Database +connectDB(); +await connectRabbit(); + +// CRON JOB (Runs every hour) +cron.schedule("0 * * * *", async () => { + logger.info("Running cleanup for expired pending signups..."); + await cleanupExpiredSignups(); +}); + app.get("/health", (req, res) => { - res.status(200).json({ - status: "ok", - service: "core-api", - timestamp: new Date().toISOString() - }) -}) - -app.use(notFound) -app.use(errorHandler) -connectRabbit() -app.listen(PORT,"127.0.0.1", () => { - logger.info(`Core API is running at http://localhost:${PORT}`); - -}) \ No newline at end of file + res.status(200).json({ + status: "ok", + service: "core-api", + timestamp: new Date().toISOString(), + }); +}); + +app.use(notFound); +app.use(errorHandler); +await setupMeili(); + +// Ensure gig.created queue exists +await getChannel().assertQueue(GIG_CREATED_EVENT, { durable: true }); +await getChannel().assertQueue("order.created", { durable: true }); +await getChannel().assertQueue(GIG_REQUEST_CREATED_EVENT, { durable: true }); + + +app.listen(PORT, "127.0.0.1", () => { + logger.info(`Core API is running at http://localhost:${PORT}`); +}); diff --git a/apps/core-api/src/services/admin.service.ts b/apps/core-api/src/services/admin.service.ts new file mode 100644 index 0000000..4d230e6 --- /dev/null +++ b/apps/core-api/src/services/admin.service.ts @@ -0,0 +1,220 @@ +import mongoose from "mongoose"; + +import { User } from "../models/user.model.js"; +import { GigModel } from "../models/gig.model.js"; +import { OrderModel } from "../models/order.model.js"; +import { ReportModel } from "../models/report.model.js"; + +interface IPopulatedUser { + _id: mongoose.Types.ObjectId; + displayName: string; + email: string; + profileImage?: string; +} + +export const getRecentActivityService = async (limit: number = 10) => { + const fetchLimit = Math.ceil(limit / 2) + 2; // Fetch slightly more to ensure we get enough after sorting + + // Fetch recent entities + const [users, gigs, orders] = await Promise.all([ + User.find().sort({ createdAt: -1 }).limit(fetchLimit), + GigModel.find().populate("primaryInfluencerId", "displayName profileImage").sort({ createdAt: -1 }).limit(fetchLimit), + OrderModel.find().sort({ createdAt: -1 }).limit(fetchLimit), + ]); + + // Map to a common activity format + const activities = [ + ...users.map(u => ({ + id: u._id, + type: "signup", + title: "New Signup", + entityName: u.email, + createdAt: u.createdAt as Date, + status: u.status, + icon: "👤" + })), + ...gigs.map(g => ({ + id: g._id, + type: "gig", + title: "New Gig Published", + entityName: g.title, + createdAt: g.createdAt as Date, + status: g.status, + icon: "🎬" + })), + ...orders.map(o => ({ + id: o._id, + type: "booking", + title: "New Booking", + entityName: `Booking #${o._id.toString().slice(-6).toUpperCase()}`, + createdAt: o.createdAt as Date, + status: o.status, + icon: "💰" + })) + ]; + + // Sort by date and take top 10 + return activities + .sort((a, b) => { + const dateA = a.createdAt instanceof Date ? a.createdAt.getTime() : 0; + const dateB = b.createdAt instanceof Date ? b.createdAt.getTime() : 0; + return dateB - dateA; + }) + .slice(0, limit); +}; + +export const getGigModerationStatsService = async () => { + const [activeGigs, reportedGigs, pausedGigs, totalOrders] = await Promise.all([ + GigModel.countDocuments({ status: "published", isDeleted: false }), + GigModel.countDocuments({ status: "flagged", isDeleted: false }), + GigModel.countDocuments({ status: "paused", isDeleted: false }), + OrderModel.find({ status: "COMPLETED" }).select("amount"), + ]); + + const totalRevenue = totalOrders.reduce((sum: number, order) => sum + (order.amount || 0), 0); + + return { + activeGigs, + reportedGigs, + pausedGigs, + totalRevenue + }; +}; +//=================PAUSE GIG================= + + +export const pauseGigService = async ( + gigId: string, + adminId: string, + reportId: string, + reason: string +) => { + const gig = await GigModel.findById(gigId); + if (!gig) throw new Error("Gig not found"); + + gig.status = "paused"; + await gig.save(); + + // 🔥 link to report + const report = await ReportModel.findOne({ reportId }); + + if (report) { + report.status = "RESOLVED"; + report.adminNotes = reason; + + report.auditTrail.push({ + action: "GIG_PAUSED", + performedBy: new mongoose.Types.ObjectId(adminId), + createdAt: new Date() + }); + + await report.save(); + } + + return gig; +}; + +//===================IGNORE GIG=================== +export const ignoreGigService = async ( + gigId: string, + adminId: string, + reportId: string, + reason: string +) => { + const gig = await GigModel.findById(gigId); + if (!gig) throw new Error("Gig not found"); + + // If gig was reported, we might want to restore it to "published" + if (gig.status === "flagged") { + gig.status = "published"; + await gig.save(); + } + + const report = await ReportModel.findOne({ reportId }); + + if (report) { + report.status = "RESOLVED"; + report.adminNotes = reason; + + report.auditTrail.push({ + action: "REPORTS_IGNORED", + performedBy: new mongoose.Types.ObjectId(adminId), + createdAt: new Date() + }); + + await report.save(); + } + + return gig; +}; + +//===================REJECT GIG=================== +export const rejectGigService = async ( + gigId: string, + adminId: string, + reportId: string, + reason: string +) => { + const gig = await GigModel.findById(gigId); + if (!gig) throw new Error("Gig not found"); + + gig.status = "rejected"; + await gig.save(); + + const report = await ReportModel.findOne({ reportId }); + + if (report) { + report.status = "RESOLVED"; + report.adminNotes = reason; + + report.auditTrail.push({ + action: "GIG_REJECTED", + performedBy: new mongoose.Types.ObjectId(adminId), + createdAt: new Date() + }); + + await report.save(); + } + + return gig; +}; + +export const getBookingsAuditService = async () => { + const orders = await OrderModel.find() + .populate("buyerId", "displayName email profileImage") + .populate("influencerId", "displayName email profileImage") + .sort({ createdAt: -1 }); + + // const totalOrders = orders.length; (removed as per ESLint unused warning) + + const completedBookings = orders.filter(o => o.status === "COMPLETED").length; + const pendingPayments = orders.filter(o => o.status === "PENDING").length; + const activeEscrows = orders.filter(o => o.status === "IN_ESCROW").length; + const totalVolume = orders + .filter(o => o.status === "COMPLETED" || o.status === "IN_ESCROW") + .reduce((sum, o) => sum + (o.amount || 0), 0); + + return { + metrics: { + completedBookings, + pendingPayments, + activeEscrows, + totalVolume + }, + bookings: orders.map(o => ({ + id: `#BK-${o._id.toString().slice(-5).toUpperCase()}`, + _id: o._id, + brand: (o.buyerId as unknown as IPopulatedUser)?.displayName || "Unknown Brand", + brandEmail: (o.buyerId as unknown as IPopulatedUser)?.email, + brandLogo: (o.buyerId as unknown as IPopulatedUser)?.profileImage || `https://ui-avatars.com/api/?name=${encodeURIComponent((o.buyerId as unknown as IPopulatedUser)?.displayName || "B")}`, + influencer: (o.influencerId as unknown as IPopulatedUser)?.displayName || "Unknown Influencer", + influencerEmail: (o.influencerId as unknown as IPopulatedUser)?.email, + influencerAvatar: (o.influencerId as unknown as IPopulatedUser)?.profileImage || `https://ui-avatars.com/api/?name=${encodeURIComponent((o.influencerId as unknown as IPopulatedUser)?.displayName || "I")}`, + + amount: `${o.currency === "INR" ? "₹" : "$"}${o.amount.toLocaleString()}`, + paymentStatus: o.status, + bookingStatus: o.workStatus.replace(/_/g, " "), + createdAt: o.createdAt + })) + }; +}; \ No newline at end of file diff --git a/apps/core-api/src/services/auth.service.ts b/apps/core-api/src/services/auth.service.ts index 2b8d7aa..12faf93 100644 --- a/apps/core-api/src/services/auth.service.ts +++ b/apps/core-api/src/services/auth.service.ts @@ -1,87 +1,503 @@ -// class AuthService { -// async loginUser(_email: string, _password: string) { -// // find user -// // compare password -// // create tokens -// // save refresh token -// // return tokens -// } - -// async refreshSession(_token: string) { -// // verify refresh token -// // issue new access token -// } -// } +import crypto from "crypto"; -// export const authService = new AuthService(); -import bcrypt from "bcrypt"; +import bcrypt from "bcrypt"; -import { signAccessToken, signRefreshToken } from "../modules/auth/auth.utils.js"; -import type { HttpError } from "../modules/auth/http-error.js"; +import { signAccessToken, signRefreshToken, verifyRefreshToken } from "../modules/auth/auth.utils.js"; +import { createHttpError } from "../modules/auth/http-error.js"; import { userRepository } from "../repositories/user.repository.js" +import { pendingSignupRepository } from "../repositories/Signup.repository.js"; +import { logger } from "../utils/logger.js"; +import { sendOtpEmail } from "../utils/sendotpEmail.js"; +import { getChannel } from "../queue/rabbit.js"; + + + + + +interface SignupInput { + fullName: string; + email: string; + password: string; + role: "INFLUENCER" | "BRAND"; + documents?: string; +} + +export const signupService = async (data: SignupInput) => { + const existingUser = await userRepository.findEmailWithPassword(data.email); + if (existingUser) { + throw createHttpError("User already exists", 409); + } + + const existingRequest = + await pendingSignupRepository.findByEmail(data.email); + + if (existingRequest) { + throw createHttpError("Sign up request already submitted", 409); + } + + const passwordHash = await bcrypt.hash(data.password, 10); + + // Generate OTP + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + + + // Hash OTP before saving + const hashedOtp = await bcrypt.hash(otp, 10); + + // Save pending signup with OTP + await pendingSignupRepository.create({ + fullName: data.fullName, + email: data.email.toLowerCase(), + passwordHash, + role: data.role, + documents: data.documents || "", + status: "PENDING", + + emailOtpHash: hashedOtp, + emailOtpExpiresAt: new Date(Date.now() + 5 * 60 * 1000), + otpAttempts: 0, + otpResendCount: 0, + otpLastSentAt: new Date(), + isEmailVerified: false, + }); + + // Send REAL Gmail OTP + console.log("SIGNUP OTP:", otp); + await sendOtpEmail(data.email, otp); + return { message: "OTP sent to your email" }; + +}; + + +export const resendSignupOtpService = async (email: string) => { + const pending = await pendingSignupRepository.findByEmail(email); + + if (!pending) { + throw createHttpError("Signup request not found", 404); + } + + if (pending.isEmailVerified) { + throw createHttpError("Email already verified", 400); + } + + const now = new Date(); + + // ⏳ Cooldown check (60 seconds) + if ( + pending.otpLastSentAt && + now.getTime() - pending.otpLastSentAt.getTime() < 60 * 1000 + ) { + throw createHttpError("Please wait before requesting another OTP", 429); + } + + // 🔁 Max resend limit + if ((pending.otpResendCount || 0) >= 5) { + throw createHttpError("Maximum resend attempts reached", 403); + } + + // 🔐 Generate new OTP + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + const hashedOtp = await bcrypt.hash(otp, 10); + + pending.emailOtpHash = hashedOtp; + pending.emailOtpExpiresAt = new Date(now.getTime() + 5 * 60 * 1000); + pending.otpResendCount = (pending.otpResendCount || 0) + 1; + pending.otpLastSentAt = now; + + await pending.save(); + + console.log("RESEND OTP:", otp); + await sendOtpEmail(email, otp); + + return { message: "OTP resent successfully" }; +}; + + interface LoginResult { accessToken: string, refreshToken: string, user: { - id: string, - role: string, + id: string, + email: string, + role: string, adminLevel: string | null } } -export const loginService = async( - email : string, - password : string +export const loginService = async ( + email: string, + password: string ): Promise => { + const user = await userRepository.findEmailWithPassword(email) + logger.info(`EMAIL: ${email}`); + if (!user) { - const err: HttpError = new Error("Invalid credentials") - err.statusCode = 401 - throw err + throw createHttpError("Invalid credentials", 401); } if (user.status !== "ACTIVE") { - const err: HttpError = new Error("User is not active") - err.statusCode = 403 - throw err + throw createHttpError("User is not active", 401); } if (!user.isEmailVerified) { - const err: HttpError = new Error("User email is not verified") - err.statusCode = 403 - throw err + throw createHttpError("User email is not verified", 401); } + const isMatch = await bcrypt.compare(password, user.password) console.log("PASSWORD FROM DB:", user.password); if (!isMatch) { - const err: HttpError = new Error("Invalid credentials") - err.statusCode = 401 - throw err + throw createHttpError("Invalid credentials", 401); } const payload = { - userId : user._id.toString(), - role : user.role, - adminLevel : user.adminLevel ?? null + userId: user._id.toString(), + role: user.role, + adminLevel: user.adminLevel ?? null } const accessToken = signAccessToken(payload) const refreshToken = signRefreshToken(payload) - await userRepository.saveRefreshToken(user._id.toString(),refreshToken) + await userRepository.saveRefreshToken(user._id.toString(), refreshToken) return { accessToken, refreshToken, user: { id: user._id.toString(), + email: user.email, role: user.role, adminLevel: user.adminLevel ?? null } } -} \ No newline at end of file +} + + +// interface RefreshResult { +// accessToken: string; +// refreshToken: string; +// } +// export const refreshTokenService = async ( +// refreshToken: string +// ): Promise => { +// if (!refreshToken) { +// const err: HttpError = new Error("Refresh token required"); +// err.statusCode = 400; +// throw err; +// } + +// let payload; +// try { +// payload = verifyRefreshToken(refreshToken); +// } catch { +// const err: HttpError = new Error("Invalid refresh token"); +// err.statusCode = 401; +// throw err; +// } + +// const user = await userRepository.findById(payload.userId); + +// if (!user || !user.refreshToken) { +// const err: HttpError = new Error("Refresh token mismatch"); +// err.statusCode = 401; +// throw err; +// } + + +// if (user.refreshToken.trim() !== refreshToken.trim()) { +// const err: HttpError = new Error("Refresh token mismatch"); +// err.statusCode = 401; +// throw err; +// } + +// const newPayload = { +// userId: user._id.toString(), +// role: user.role, +// adminLevel: user.adminLevel ?? null, +// }; + +// const newAccessToken = signAccessToken(newPayload); +// const newRefreshToken = signRefreshToken(newPayload); + +// await userRepository.saveRefreshToken(user._id.toString(), newRefreshToken); + +// return { +// accessToken: newAccessToken, +// refreshToken: newRefreshToken, +// user: { +// id: user._id.toString(), +// email: user.email, +// role: user.role, +// adminLevel: user.adminLevel ?? null, +// }, +// }; +// }; + +interface RefreshResult { + accessToken: string; + refreshToken: string; + user: { + id: string; + email: string; + role: string; + adminLevel: string | null; + }; +} + +export const refreshTokenService = async ( + refreshToken: string +): Promise => { + if (!refreshToken) { + + throw createHttpError("Refresh token required", 401); + } + + let payload; + try { + payload = verifyRefreshToken(refreshToken); + } catch { + + throw createHttpError("Invalid refresh token", 401); + } + + const user = await userRepository.findById(payload.userId); + + // FIRST check user existence + if (!user || !user.refreshToken) { + + throw createHttpError("Refresh token mismatch", 401); + } + + + // Compare after narrowing + if (user.refreshToken.trim() !== refreshToken.trim()) { + + throw createHttpError("Refresh token mismatch", 401); + } + + const newPayload = { + userId: user._id.toString(), + role: user.role, + adminLevel: user.adminLevel ?? null, + }; + + const newAccessToken = signAccessToken(newPayload); + const newRefreshToken = signRefreshToken(newPayload); + + await userRepository.saveRefreshToken( + user._id.toString(), + newRefreshToken + ); + + return { + accessToken: newAccessToken, + refreshToken: newRefreshToken, + user: { + id: user._id.toString(), + email: user.email, + role: user.role, + adminLevel: user.adminLevel ?? null, + }, + }; +}; + + +export const logoutService = async (userId: string) => { + if (!userId) { + + throw createHttpError("User not authenticated", 401); + } + + // Invalidate refresh token + await userRepository.saveRefreshToken(userId, ""); + + return { message: "Logged out successfully" }; +}; + + +// ================= VERIFY SIGNUP OTP ================= +export const verifySignupOtpService = async ( + email: string, + otp: string +): Promise => { + + const pending = await pendingSignupRepository.findByEmail(email); + + if (!pending) { + + throw createHttpError("Signup request not found", 409); + } + + if (pending.isEmailVerified) { + + throw createHttpError("Email already verified", 409); + } + + if (!pending.emailOtpHash || !pending.emailOtpExpiresAt) { + + throw createHttpError("OTP not found", 409); + } + + if (pending.emailOtpExpiresAt < new Date()) { + + throw createHttpError("OTP expired", 409); + } + + const isMatch = await bcrypt.compare( + otp, + pending.emailOtpHash + ); + + if (!isMatch) { + + throw createHttpError("Invalid OTP", 409); + } + + // SUCCESS + pending.isEmailVerified = true; + pending.emailOtpHash = null; + pending.emailOtpExpiresAt = null; + + await pending.save(); + + try { + getChannel().sendToQueue( + "user.created", + Buffer.from(JSON.stringify({ userId: pending._id.toString(), email: pending.email, fullName: pending.fullName, role: pending.role })), + { persistent: true } + ); + } catch (err) { + logger.error("Failed to publish user.created event", err); + } +}; + + + +// forgotPasswordService + +export const forgotPasswordService = async (email: string) => { + const user = await userRepository.findEmailWithPassword(email); + + if (!user) return; + + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + console.log("RESET OTP:", otp); + + const hashedOtp = await bcrypt.hash(otp, 10); + + const expiry = new Date(Date.now() + 10 * 60 * 1000); + + await userRepository.saveResetOtp( + user._id.toString(), + hashedOtp, + expiry + ); + + console.log("RESEND OTP:", otp); + await sendOtpEmail(email, otp); + + logger.info(`Reset OTP sent to ${email}`); +}; + +// verifyOtpService + +export const verifyOtpService = async ( + email: string, + otp: string +): Promise => { + + const user = await userRepository.findByEmailWithResetFields(email); + + if (!user || !user.resetOtp || !user.resetOtpExpiry) { + + throw createHttpError("Invalid request", 409); + } + + if (user.resetOtpExpiry < new Date()) { + + throw createHttpError("OTP expired", 409); + } + + const isMatch = await bcrypt.compare(otp, user.resetOtp); + + if (!isMatch) { + + throw createHttpError("Invalid OTP", 409); + } + + const resetSessionToken = crypto.randomBytes(32).toString("hex"); + + const sessionExpiry = new Date(Date.now() + 10 * 60 * 1000); + + await userRepository.saveResetSession( + user._id.toString(), + resetSessionToken, + sessionExpiry + ); + + return resetSessionToken; +}; + +// resetPasswordService + +export const resetPasswordService = async ( + email: string, + newPassword: string, + resetSessionToken: string +) => { + + const user = await userRepository.findByEmailWithResetFields(email); + + if ( + !user || + !user.resetSessionToken || + !user.resetSessionExpiry + ) { + + throw createHttpError("Invalid request", 409); + } + + if (user.resetSessionToken !== resetSessionToken) { + + throw createHttpError("Invalid session", 409); + } + + if (user.resetSessionExpiry < new Date()) { + + throw createHttpError("Session expired", 409); + } + + const hashedPassword = await bcrypt.hash(newPassword, 10); + + await userRepository.updatePassword( + user._id.toString(), + hashedPassword + ); + + await userRepository.clearResetSession(user._id.toString()); + + logger.info(`Password reset successful for ${email}`); +}; + + + + + +export const pendingProfileService = async (email: string, profileData: Record) => { + const pending = await pendingSignupRepository.findByEmail(email); + + if (!pending) { + throw createHttpError("Signup request not found", 404); + } + + // Update profile data + await pendingSignupRepository.updateProfileData(email, profileData); + + return { message: "Profile data saved successfully" }; +}; diff --git a/apps/core-api/src/services/availability.service.ts b/apps/core-api/src/services/availability.service.ts new file mode 100644 index 0000000..48fcd33 --- /dev/null +++ b/apps/core-api/src/services/availability.service.ts @@ -0,0 +1,104 @@ +import mongoose from "mongoose"; +// import { Types } from "mongoose"; + +// import { UserRole } from "../models/user.model.js"; +// import { createHttpError } from "../modules/auth/http-error.js"; +// import { InfluencerProfile } from "../models/influencer.model.js"; +// import { findAvailabilityByInfluencer, upsertAvailability } from "../repositories/availability.repository.js"; +// import type { DateOverride, WeeklyRule } from "../types/availability.types.js"; + + +// interface SetAvailabilityInput { +// timezone: string; +// weeklyRules: WeeklyRule[]; +// dateOverrides: DateOverride[]; +// } + +// export const setAvailabilityService = async ( +// userId: string, +// role: string, +// input: SetAvailabilityInput +// ) => { +// if (role !== UserRole.INFLUENCER) { +// throw createHttpError("Only influencers can set availability", 403); +// } + +// const influencerProfile = await InfluencerProfile.findOne({ +// userId: new Types.ObjectId(userId) +// }); + +// if (!influencerProfile) { +// throw createHttpError("Influencer profile not found", 404); +// } + +// const availability = await upsertAvailability( +// influencerProfile._id, +// { +// influencerProfileId: influencerProfile._id, +// timezone: input.timezone, +// weeklyRules: input.weeklyRules, +// dateOverrides: input.dateOverrides +// } +// ); + +// return availability; +// }; + +// export const getAvailabilityService = async ( +// influencerProfileId: string +// ) => { +// const availability = await findAvailabilityByInfluencer( +// new Types.ObjectId(influencerProfileId) +// ); + +// if (!availability) { +// throw createHttpError("Availability not found", 404); +// } + +// return availability; +// }; + + +import { AvailabilityRepository } from "../repositories/availability.repository.js"; + +const repo = new AvailabilityRepository(); + +export class AvailabilityService { + async addUnavailableDate( + influencerId: string, + date: Date, + reason?: string + ) { + return repo.addUnavailableDate(influencerId, date, reason); + } + + async removeUnavailableDate(influencerId: string, date: Date) { + return repo.removeUnavailableDate(influencerId, date); + } + + // FINAL SIMPLE LOGIC + async isAvailableToday(influencerId: string) { + try { + if (!influencerId || !mongoose.Types.ObjectId.isValid(influencerId)) { + console.warn("Invalid influencerId for availability check:", influencerId); + return true; // Fail open + } + + const today = new Date(); + const availability = await repo.getAvailability(influencerId); + + // If no record → available + if (!availability || !availability.overrides) return true; + + const isBlocked = availability.overrides.find( + (o: { date: Date | string }) => + new Date(o.date).toDateString() === today.toDateString() + ); + + return !isBlocked; + } catch (error) { + console.error("isAvailableToday error:", error); + return true; // Fail open + } + } +} \ No newline at end of file diff --git a/apps/core-api/src/services/brand.service.ts b/apps/core-api/src/services/brand.service.ts new file mode 100644 index 0000000..1e2eb24 --- /dev/null +++ b/apps/core-api/src/services/brand.service.ts @@ -0,0 +1,17 @@ +import { findBrandById, findBrandByUserId } from "../repositories/brand.repository.js"; + +export const getPublicBrandProfileService = async (brandId: string) => { + + let brand = await findBrandById(brandId); + + if (!brand) { + brand = await findBrandByUserId(brandId); + } + + if (!brand) { + const error = Object.assign(new Error("Brand not found"), { statusCode: 404 }); + throw error; + } + + return brand; +}; \ No newline at end of file diff --git a/apps/core-api/src/services/chat.service.ts b/apps/core-api/src/services/chat.service.ts new file mode 100644 index 0000000..39238f6 --- /dev/null +++ b/apps/core-api/src/services/chat.service.ts @@ -0,0 +1,196 @@ +import mongoose from "mongoose"; + +import { MessageModel } from "../models/chat.model.js"; +import { GigRequestModel } from "../models/gig-request.model.js"; +import { OrderModel } from "../models/order.model.js"; +import { + findMessagesByGigRequest, + aggregateConversations, + addMessage +} from "../repositories/chat.repository.js"; + +// Get messages by gigRequestId +export const getMessages = async (gigRequestId: string) => { + return findMessagesByGigRequest(new mongoose.Types.ObjectId(gigRequestId)); +}; + +// Get all conversations for sidebar +export const getConversations = async (userId: string, role?: string) => { + return aggregateConversations(userId, role); +}; + +// Send message — only allowed on accepted gig requests +export const sendMessage = async ( + senderId: string, + gigRequestId: string, + content: string, + messageType: "TEXT" | "PROPOSAL" = "TEXT", + proposalData?: { date: Date; time: string; status: "PENDING" | "ACCEPTED" | "REJECTED" } +) => { + const request = await GigRequestModel.findById(gigRequestId); + + if (!request) { + throw new Error("Gig request not found"); + } + + if (request.status !== "accepted") { + throw new Error("Chat is only available for accepted gig requests"); + } + + const receiverId = request.brandId.toString() === senderId + ? request.influencerId.toString() + : request.brandId.toString(); + + const messageData = { + gigRequestId: new mongoose.Types.ObjectId(gigRequestId), + senderId, + receiverId, + content, + status: "SENT", + messageType, + proposalData + }; + + return addMessage(messageData as { + gigRequestId: mongoose.Types.ObjectId; + senderId: string; + receiverId: string; + content: string; + status: string; + messageType?: string; + proposalData?: Record; + }); +} + +export const respondToProposal = async (messageId: string, userId: string, status: "ACCEPTED" | "REJECTED") => { + const message = await MessageModel.findById(messageId); + + if (!message) { + throw new Error("Message not found"); + } + + if (message.messageType !== "PROPOSAL") { + throw new Error("Target message is not a proposal"); + } + + if (message.receiverId.toString() !== userId) { + throw new Error("Only the receiver can respond to this proposal"); + } + + if (message.proposalData?.status !== "PENDING") { + throw new Error("Proposal is already processed"); + } + + message.proposalData.status = status; + await message.save(); + + // Create a system message for the agreement + if (status === "ACCEPTED") { + await addMessage({ + gigRequestId: message.gigRequestId, + senderId: userId, // The one who accepted + receiverId: message.senderId.toString(), + content: `✅ Proposal Accepted: ${message.proposalData.date.toLocaleDateString()} at ${message.proposalData.time}`, + status: "SENT", + messageType: "TEXT" + }); + } + + return message; +} + +export const submitDeliverable = async ( + senderId: string, + gigRequestId: string, + deliverableData: { url: string; mediaType: "VIDEO" | "IMAGE" } +) => { + const request = await GigRequestModel.findById(gigRequestId); + if (!request) throw new Error("Gig request not found"); + + // Only the influencer can submit deliverables + if (request.influencerId.toString() !== senderId) { + throw new Error("Only the influencer can submit deliverables"); + } + + // Find the associated order to ensure it's paid/in-escrow + const order = await OrderModel.findOne({ connectionId: gigRequestId, status: "IN_ESCROW" }); + if (!order) throw new Error("No active escrow order found for this request"); + + const receiverId = request.brandId.toString(); + + const message = await addMessage({ + gigRequestId: new mongoose.Types.ObjectId(gigRequestId), + senderId, + receiverId, + content: "Final deliverable submitted for review.", + status: "SENT", + messageType: "DELIVERABLE", + deliverableData: { + ...deliverableData, + status: "PENDING" + } + }); + + // Also update order workStatus + order.workStatus = "SUBMITTED"; + order.deliverableUrl = deliverableData.url; + await order.save(); + + return message; +}; + +export const respondToDeliverable = async ( + messageId: string, + userId: string, + status: "ACCEPTED" | "REJECTED", + rejectionNote?: string +) => { + const message = await MessageModel.findById(messageId); + if (!message) throw new Error("Message not found"); + if (message.messageType !== "DELIVERABLE") throw new Error("Message is not a deliverable"); + if (message.receiverId.toString() !== userId) throw new Error("Only the receiver can respond"); + if (!message.deliverableData || message.deliverableData.status !== "PENDING") throw new Error("Deliverable already processed or not found"); + + message.deliverableData.status = status; + if (status === "REJECTED") { + message.deliverableData.rejectionNote = rejectionNote || "Revision requested"; + } + await message.save(); + + const order = await OrderModel.findOne({ connectionId: message.gigRequestId, status: "IN_ESCROW" }); + if (!order) throw new Error("Order not found"); + + if (status === "ACCEPTED") { + // 🔥 RELEASE ESCROW + order.workStatus = "APPROVED"; + order.status = "COMPLETED"; + order.escrowStatus = "RELEASED"; + order.payoutStatus = "AVAILABLE"; + order.availableAt = new Date(); + await order.save(); + + // Create system notification + await addMessage({ + gigRequestId: message.gigRequestId, + senderId: userId, + receiverId: message.senderId.toString(), + content: "Collaboration Successful! The order is now complete.", + status: "SENT", + messageType: "ORDER_COMPLETED" + }); + } else { + order.workStatus = "NOT_STARTED"; + await order.save(); + + await addMessage({ + gigRequestId: message.gigRequestId, + senderId: userId, + receiverId: message.senderId.toString(), + content: `❌ Deliverable Rejected. Reason: ${rejectionNote || "No note provided."}`, + status: "SENT", + messageType: "SYSTEM" + }); + } + + return message; +} \ No newline at end of file diff --git a/apps/core-api/src/services/collaboration.service.ts b/apps/core-api/src/services/collaboration.service.ts new file mode 100644 index 0000000..a80be23 --- /dev/null +++ b/apps/core-api/src/services/collaboration.service.ts @@ -0,0 +1,80 @@ +import { Types } from "mongoose"; + +import { GigModel } from "../models/gig.model.js"; +import { InfluencerProfile } from "../models/influencer.model.js"; +import { + createCollaboration, + findCollaborationById, + updateCollaborationStatus +} from "../repositories/collaboration.repository.js"; + +export const inviteCollaboratorsService = async ( + gigId: string, + userId: string, + collaboratorIds: string[] +) => { + const gig = await GigModel.findById(gigId); + + if (!gig) { + throw Object.assign(new Error("Gig not found"), { statusCode: 404 }); + } + + if (gig.primaryInfluencerId.toString() !== userId) { + throw Object.assign(new Error("Unauthorized"), { statusCode: 403 }); + } + + const collaborations = []; + + for (const id of collaboratorIds) { + const profile = await InfluencerProfile.findById(id); + if (!profile) continue; + + const collab = await createCollaboration({ + gigId: gig._id, + primaryInfluencerId: gig.primaryInfluencerId, + invitedInfluencerId: new Types.ObjectId(id), + status: "pending" + }); + + collaborations.push(collab); + } + + return collaborations; +}; + +export const respondToCollaborationService = async ( + collaborationId: string, + userId: string, + action: "accepted" | "rejected" +) => { + const collaboration = await findCollaborationById( + new Types.ObjectId(collaborationId) + ); + + if (!collaboration) { + throw Object.assign(new Error("Collaboration not found"), { + statusCode: 404 + }); + } + + if (collaboration.invitedInfluencerId.toString() !== userId) { + throw Object.assign(new Error("Unauthorized"), { + statusCode: 403 + }); + } + + const updated = await updateCollaborationStatus( + collaboration._id, + action + ); + + if (action === "accepted") { + await GigModel.findByIdAndUpdate(collaboration.gigId, { + $addToSet: { + influencerIds: collaboration.invitedInfluencerId + } + }); + } + + return updated; +}; \ No newline at end of file diff --git a/apps/core-api/src/services/connection.service.ts b/apps/core-api/src/services/connection.service.ts new file mode 100644 index 0000000..e5f5158 --- /dev/null +++ b/apps/core-api/src/services/connection.service.ts @@ -0,0 +1,189 @@ +import mongoose from "mongoose"; + +import { ConnectionRepository } from "../repositories/connection.repository.js"; +import { AvailabilityService } from "../services/availability.service.js"; +import { MessageModel } from "../models/chat.model.js"; +import { GigModel } from "../models/gig.model.js"; +import { publishEvent } from "../queue/publisher.js"; +import { GIG_REQUEST_CREATED_EVENT } from "../queue/events.js"; +import { logger } from "../utils/logger.js"; + +const repo = new ConnectionRepository(); +const availabilityService = new AvailabilityService(); + +export class ConnectionService { + // Send request + async sendRequest(brandId: string, influencerId: string, gigId?: string, note?: string) { + if (!influencerId) { + throw new Error("influencerId is required"); + } + + if (brandId === influencerId) { + throw new Error("You cannot request your own gig"); + } + + const existing = await repo.findExisting(brandId, influencerId, gigId); + + if (existing) { + throw new Error("Already requested this gig."); + } + + const isAvailable = + await availabilityService.isAvailableToday(influencerId); + + const connection = (await repo.create({ + brandId, + influencerId, + gigId, + note, + })) as unknown as { _id: string }; + + await this.sendAutoBookingMessages(brandId, influencerId, connection._id.toString(), gigId, note); + + publishEvent(GIG_REQUEST_CREATED_EVENT, { + id: connection._id, + brandId, + influencerId, + gigId, + note, + }).catch((err) => logger.error(`Failed to publish gig_request.created: ${err}`)); + + return { + connection, + isAvailable, + }; + } + + // Helper for auto-messages + private async sendAutoBookingMessages(brandId: string, influencerId: string, connectionId: string, gigId?: string, note?: string) { + let gigTitle = "your service"; + if (gigId && mongoose.Types.ObjectId.isValid(gigId)) { + const gig = await GigModel.findById(gigId); + if (gig) gigTitle = gig.title; + } else if (gigId) { + console.warn("Invalid gigId for auto-messages:", gigId); + } + + // 1. User Message (Intent) + let content = `Hi, I'd like to book your "${gigTitle}".`; + if (note) { + content += `\n\nNote from brand: ${note}`; + } + + await MessageModel.create({ + gigRequestId: new mongoose.Types.ObjectId(connectionId), + senderId: brandId, + receiverId: influencerId, + content, + status: "SENT", + }); + + // 2. Status Message + await MessageModel.create({ + gigRequestId: new mongoose.Types.ObjectId(connectionId), + senderId: brandId, + receiverId: influencerId, + content: `📌 New Booking Request for: ${gigTitle}`, + status: "SENT", + }); + } + + // Accept request + CREATE CHAT + async acceptRequest(connectionId: string) { + const connection = await repo.findById(connectionId); + + if (!connection) { + throw new Error("Connection not found"); + } + + if (connection.status !== "pending") { + throw new Error("Already processed"); + } + + const updatedConnection = await repo.updateStatus( + connectionId, + "accepted" + ); + + // FIX: handle possible null + if (!updatedConnection) { + throw new Error("Failed to update connection"); + } + await publishEvent("gig_request.accepted", { + id: connectionId, + brandId: connection.brandId, + influencerId: connection.influencerId, + }); + await MessageModel.create({ + gigRequestId: new mongoose.Types.ObjectId(connectionId), + senderId: connection.influencerId, + receiverId: connection.brandId, + content: "✅ Influencer accepted the request", + status: "SENT", + }); + + // CONVERSATION ALREADY CREATED ON REQUEST + return updatedConnection; + } + + async rejectRequest(connectionId: string) { + const connection = await repo.findById(connectionId); + + if (!connection) { + throw new Error("Connection not found"); + } + + if (connection.status !== "pending") { + throw new Error("Already processed"); + } + + const updatedConnection = await repo.updateStatus( + connectionId, + "rejected" + ); + + if (!updatedConnection) { + throw new Error("Failed to update connection"); + } + + // ✅ Publish AFTER update + await publishEvent("gig_request.rejected", { + id: connectionId, + brandId: connection.brandId, + influencerId: connection.influencerId, + }); + + return updatedConnection; +} + // // Reject request + // async rejectRequest(connectionId: string) { + // const connection = await repo.findById(connectionId); + + // if (!connection) { + // throw new Error("Connection not found"); + // } + + // if (connection.status !== "pending") { + // throw new Error("Already processed"); + // } + + // return repo.updateStatus(connectionId, "rejected"); + // } + + // Get connection by ID + async getConnectionById(id: string) { + return repo.findById(id); + } + + // Get connection between two users + async getConnectionBetween(u1: string, u2: string, gigId?: string) { + const c1 = await repo.findExisting(u1, u2, gigId); + if (c1) return c1; + return repo.findExisting(u2, u1, gigId); + } + + // Get connections + async getMyConnections(userId: string, role?: string) { + return repo.findMyConnections(userId, role); + } +} \ No newline at end of file diff --git a/apps/core-api/src/services/gig-request.service.ts b/apps/core-api/src/services/gig-request.service.ts new file mode 100644 index 0000000..b3eedf8 --- /dev/null +++ b/apps/core-api/src/services/gig-request.service.ts @@ -0,0 +1,117 @@ +import { GigRequestRepository } from "../repositories/gig-request.repository.js"; +import { publishEvent } from "../queue/publisher.js"; +import { + GIG_REQUEST_CREATED_EVENT, + GIG_REQUEST_ACCEPTED_EVENT, + GIG_REQUEST_REJECTED_EVENT +} from "../queue/events.js"; + +const repo = new GigRequestRepository(); + +export class GigRequestService { + // Brand sends a gig request to an influencer + async sendRequest( + brandId: string, + influencerId: string, + gigId: string, + note?: string + ) { + if (!influencerId) { + throw new Error("influencerId is required"); + } + if (!gigId) { + throw new Error("gigId is required"); + } + + const existing = await repo.findExisting(brandId, influencerId, gigId); + if (existing) { + throw new Error("Already requested this gig."); + } + + const gigRequest = await repo.create({ + brandId, + influencerId, + gigId, + ...(note ? { note } : {}), + }) as unknown as { _id: string }; + + await publishEvent(GIG_REQUEST_CREATED_EVENT, { + id: gigRequest._id, + brandId, + influencerId, + gigId, + note, + }); + + return { gigRequest }; + } + + // Influencer accepts — chat becomes enabled + async acceptRequest(gigRequestId: string) { + const request = await repo.findById(gigRequestId); + + if (!request) { + throw new Error("Gig request not found"); + } + if (request.status !== "pending") { + throw new Error("Already processed"); + } + + const updated = await repo.updateStatus(gigRequestId, "accepted"); + + if (!updated) { + throw new Error("Failed to update gig request"); + } + + await publishEvent(GIG_REQUEST_ACCEPTED_EVENT, { + id: request._id || gigRequestId, + brandId: request.brandId, + influencerId: request.influencerId, + gigId: request.gigId, + }); + + return updated; + } + + // Influencer rejects the request + async rejectRequest(gigRequestId: string) { + const request = await repo.findById(gigRequestId); + + if (!request) { + throw new Error("Gig request not found"); + } + if (request.status !== "pending") { + throw new Error("Already processed"); + } + + const updated = await repo.updateStatus(gigRequestId, "rejected"); + + if (!updated) { + throw new Error("Failed to update gig request"); + } + + await publishEvent(GIG_REQUEST_REJECTED_EVENT, { + id: request._id || gigRequestId, + brandId: request.brandId, + influencerId: request.influencerId, + gigId: request.gigId, + }); + + return updated; + } + + // Get a single request by ID + async getById(id: string) { + return repo.findById(id); + } + + // Get all requests for the calling user (brand or influencer) + async getMyRequests(userId: string) { + return repo.findMyRequests(userId); + } + + // Find existing request between brand + influencer for a gig + async getRequestBetween(brandId: string, influencerId: string, gigId: string) { + return repo.findExisting(brandId, influencerId, gigId); + } +} diff --git a/apps/core-api/src/services/gig.service.ts b/apps/core-api/src/services/gig.service.ts new file mode 100644 index 0000000..7416afe --- /dev/null +++ b/apps/core-api/src/services/gig.service.ts @@ -0,0 +1,512 @@ +import mongoose from "mongoose"; +import { Types } from "mongoose"; +import type { JwtPayload } from "jsonwebtoken"; + +// import { GigModel } from "../models/gig.model.js"; +import { create_gig, findActiveGigById, findGigById, findGigsByInfluencer, findPublishedGigById, findPublishedGigs, getAllGigs, softDeleteGig } from "../repositories/gig.repository.js"; +import { type GigDeliverable, type GigType, type Platform } from "../types/gig.type.js"; +import { type GigDocument } from "../types/gig.type.js"; +import { InfluencerProfile, type IInfluencerProfile } from "../models/influencer.model.js"; +import { UserRole } from "../models/user.model.js"; + + +/* ================= TYPES ================= */ + +interface GigQuery { + category?: string; + minPrice?: string; + maxPrice?: string; + sort?: "price_asc" | "price_desc"; + status?: string; +} + +interface HttpError extends Error { + statusCode?: number; +} + + + +//=================GET TOTAL GIGS================= +export const getTotalGigsService = async () => { + const gigCount = await getAllGigs(); + return gigCount.length; +} + +/* ================= LIST GIGS ================= */ + +export const listGigsService = async ( + query: GigQuery, + page: number, + limit: number +) => { + const skip = (page - 1) * limit; + + const filter: Record = { + isDeleted: false + }; + + if (query.status && query.status !== "all") { + filter.status = query.status; + } else if (!query.status) { + // Default for public marketplace + filter.status = "published"; + } + + // Category filter + if (query.category) { + filter.category = query.category; + } + + // Price filter + if (query.minPrice || query.maxPrice) { + const priceFilter: { + $gte?: number; + $lte?: number; + } = {}; + + if (query.minPrice) { + priceFilter.$gte = Number(query.minPrice); + } + + if (query.maxPrice) { + priceFilter.$lte = Number(query.maxPrice); + } + + filter["pricing.basePrice"] = priceFilter; + } + + // Sorting + let sort: Record = { createdAt: -1 }; + + if (query.sort === "price_asc") { + sort = { "pricing.basePrice": 1 }; + } + + if (query.sort === "price_desc") { + sort = { "pricing.basePrice": -1 }; + } + + const { gigs, total } = await findPublishedGigs( + filter, + sort, + skip, + limit + ); + + return { + data: gigs, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit) + } + }; +}; + +/* ================= GET GIG DETAILS ================= */ + +export const getGigDetailsService = async (gigId: string) => { + if (!mongoose.Types.ObjectId.isValid(gigId)) { + const err = new Error("Invalid gig ID") as HttpError; + err.statusCode = 400; + throw err; + } + + const gig = await findPublishedGigById(gigId); + if (!gig) { + const err = new Error("Gig not found") as HttpError; + err.statusCode = 404; + throw err; + } + + return gig; +}; + +/* ================= TYPES ================= */ + +interface CreateGigInput { + title: string; + shortDescription: string; + platform: Platform; + gigType: GigType; + + category: string; + tags?: string[]; + + deliverables?: GigDeliverable[]; + + basePrice?: number; + currency?: "INR" | "USD"; + negotiationAllowed?: boolean; + deliveryTimeInDays?: number; + revisionsIncluded?: number; + + maxBookingsPerSlot?: number; + + collaboratorIds?: string[]; + bannerUrl?: string; +} + +/* ================= CREATE GIG ================= */ + +export const createGigService = async ( + userId: string, + role: string, + input: CreateGigInput +): Promise<{ + gig: GigDocument; + collaborators: string[]; +}> => { + if (role !== UserRole.INFLUENCER) { + throw Object.assign(new Error("only influencers can create gigs"), { + statusCode: 403 + }); + } + + const influencerProfile = await InfluencerProfile.findOne({ + userId: new Types.ObjectId(userId) + }); + + if (!influencerProfile) { + throw Object.assign(new Error("influencer profile not found"), { + statusCode: 404 + }); + } + + const gig = await create_gig({ + title: input.title, + shortDescription: input.shortDescription, + platform: input.platform, + gigType: input.gigType, + category: input.category, + tags: input.tags || [], + bannerUrl: input.bannerUrl || "", + + influencerIds: [influencerProfile._id as Types.ObjectId], + primaryInfluencerId: influencerProfile._id as Types.ObjectId, + + pricing: { + basePrice: input.basePrice ?? 0, + currency: input.currency ?? "INR", + negotiationAllowed: input.negotiationAllowed || false, + deliveryTimeInDays: input.deliveryTimeInDays ?? 1, + revisionsIncluded: input.revisionsIncluded ?? 0 + }, + + deliverables: input.deliverables || [], + + status: "draft", + isDeleted: false, + reportCount: 0, + maxBookingsPerSlot: input.maxBookingsPerSlot || 1 + }); + + + return { + gig, + collaborators: input.collaboratorIds ?? [] + } +}; + +export const updateGigDeliverablesService = async ( + gigId: string, + userId: string, + deliverables: GigDeliverable[] +) => { + const gig = await findGigById(gigId); + if (!gig) { + throw Object.assign(new Error("Gig not found"), { statusCode: 404 }); + } + + const influencerProfile = await InfluencerProfile.findOne({ + userId: new Types.ObjectId(userId) + }); + + if (!influencerProfile) { + throw Object.assign(new Error("Influencer profile not found"), { + statusCode: 404 + }); + } + + if (gig.primaryInfluencerId.toString() !== influencerProfile._id.toString()) { + throw Object.assign(new Error("Unauthorized"), { + statusCode: 403 + }); + } + + gig.deliverables = deliverables; + await gig.save(); + + return gig; +}; + +export const updateGigPricingService = async ( + gigId: string, + userId: string, + pricingInput: { + basePrice: number; + currency: "INR" | "USD"; + negotiationAllowed?: boolean; + deliveryTimeInDays: number; + revisionsIncluded: number; + } +) => { + const gig = await findGigById(gigId); + if (!gig) { + throw Object.assign(new Error("Gig not found"), { statusCode: 404 }); + } + + const influencerProfile = await InfluencerProfile.findOne({ + userId: new Types.ObjectId(userId) + }); + + if (!influencerProfile) { + throw Object.assign(new Error("Influencer profile not found"), { statusCode: 404 }); + } + + if (gig.primaryInfluencerId.toString() !== influencerProfile._id.toString()) { + throw Object.assign(new Error("Unauthorized"), { statusCode: 403 }); + } + + gig.pricing = { + basePrice: pricingInput.basePrice, + currency: pricingInput.currency, + negotiationAllowed: pricingInput.negotiationAllowed ?? false, + deliveryTimeInDays: pricingInput.deliveryTimeInDays, + revisionsIncluded: pricingInput.revisionsIncluded + }; + + await gig.save(); + + return gig; +}; + + + +export const publishGigService = async ( + gigId: string, + userId: string +) => { + const gig = await findGigById(gigId); + if (!gig) { + throw Object.assign(new Error("Gig not found"), { statusCode: 404 }); + } + + // 🔥 FIX: resolve influencer profile from userId + const influencerProfile = await InfluencerProfile.findOne({ + userId: new Types.ObjectId(userId) + }); + + if (!influencerProfile) { + throw Object.assign(new Error("Influencer profile not found"), { + statusCode: 404 + }); + } + + if (gig.primaryInfluencerId.toString() !== influencerProfile._id.toString()) { + throw Object.assign(new Error("Unauthorized to publish this gig"), { + statusCode: 403 + }); + } + + gig.status = "published"; + await gig.save(); + + return gig; +}; +//* ================= EDIT GIG ================= */ + + + + +type EditableGigFields = { + title?: string; + shortDescription?: string; + category?: string; + tags?: string[]; + deliverables?: GigDeliverable[]; + pricing?: { + basePrice: number; + currency: "INR" | "USD"; + negotiationAllowed?: boolean; + }; + maxBookingsPerSlot?: number; + status?: "draft" | "published" | "paused" | "archived"; + bannerUrl?: string; +}; + +export const editGigService = async ( + gigId: string, + user: JwtPayload, + updateData: EditableGigFields +) => { + if (!mongoose.Types.ObjectId.isValid(gigId)) { + throw Object.assign(new Error("Invalid gig ID"), { statusCode: 400 }); + } + + if (user.role !== "INFLUENCER") { + throw Object.assign(new Error("Only influencers can edit gigs"), { + statusCode: 403 + }); + } + + const influencerProfile = await InfluencerProfile.findOne({ + userId: user.userId + }); + + if (!influencerProfile) { + throw Object.assign(new Error("Influencer profile not found"), { + statusCode: 404 + }); + } + + const gig = await findActiveGigById(gigId); + + if (!gig) { + throw Object.assign(new Error("Gig not found"), { statusCode: 404 }); + } + + if ( + gig.primaryInfluencerId.toString() !== + influencerProfile._id.toString() + ) { + throw Object.assign(new Error("Unauthorized"), { + statusCode: 403 + }); + } + + if (gig.status === "archived") { + throw Object.assign(new Error("Archived gig cannot be edited"), { + statusCode: 400 + }); + } + + // 🔥 SAFE FIELD UPDATES + + if (updateData.title !== undefined) { + gig.title = updateData.title; + } + + if (updateData.shortDescription !== undefined) { + gig.shortDescription = updateData.shortDescription; + } + + if (updateData.category !== undefined) { + gig.category = updateData.category; + } + + if (updateData.tags !== undefined) { + gig.tags = updateData.tags; + } + + if (updateData.deliverables !== undefined) { + gig.deliverables = updateData.deliverables; + } + + if (updateData.pricing !== undefined) { + if (updateData.pricing.basePrice !== undefined) { + gig.pricing.basePrice = updateData.pricing.basePrice; + } + + if (updateData.pricing.currency !== undefined) { + gig.pricing.currency = updateData.pricing.currency; + } + + if (updateData.pricing.negotiationAllowed !== undefined) { + gig.pricing.negotiationAllowed = + updateData.pricing.negotiationAllowed; + } + } + + if (updateData.maxBookingsPerSlot !== undefined) { + gig.maxBookingsPerSlot = updateData.maxBookingsPerSlot; + } + + if (updateData.status !== undefined) { + gig.status = updateData.status; + } + + if (updateData.bannerUrl !== undefined) { + gig.bannerUrl = updateData.bannerUrl; + } + + await gig.save(); + + return gig; +}; + + +//* ================= DELETE GIG ================= + + + +export const deleteGigService = async ( + gigId: string, + user: JwtPayload +): Promise => { + // Validate ObjectId + if (!mongoose.Types.ObjectId.isValid(gigId)) { + const err: HttpError = new Error("Invalid gig ID"); + err.statusCode = 400; + throw err; + } + + // Only influencers and admins allowed + if (user.role !== UserRole.INFLUENCER && user.role !== UserRole.ADMIN) { + const err: HttpError = new Error("Only influencers and admins can delete gigs"); + err.statusCode = 403; + throw err; + } + + // Find gig first + const gig = await findActiveGigById(gigId); + if (!gig) { + const err: HttpError = new Error("Gig not found or already deleted"); + err.statusCode = 404; + throw err; + } + + // If user is an influencer, check ownership + if (user.role === UserRole.INFLUENCER) { + // Find influencer profile + const influencerProfile: IInfluencerProfile | null = + await InfluencerProfile.findOne({ + userId: user.userId + }); + + if (!influencerProfile) { + const err: HttpError = new Error("Influencer profile not found"); + err.statusCode = 404; + throw err; + } + + // Ownership check + if ( + gig.primaryInfluencerId.toString() !== + influencerProfile._id.toString() + ) { + const err: HttpError = new Error( + "You are not allowed to delete this gig" + ); + err.statusCode = 403; + throw err; + } + } + + // Soft delete (influencer ownership confirmed or user is admin) + await softDeleteGig(gigId); +}; + +export const getMyGigsService = async (userId: string) => { + const influencerProfile = await InfluencerProfile.findOne({ userId }); + + if (!influencerProfile) { + throw Object.assign(new Error("Influencer profile not found"), { statusCode: 404 }); + } + + return findGigsByInfluencer(influencerProfile._id); +}; + + + diff --git a/apps/core-api/src/services/media.service.ts b/apps/core-api/src/services/media.service.ts new file mode 100644 index 0000000..3762c40 --- /dev/null +++ b/apps/core-api/src/services/media.service.ts @@ -0,0 +1,62 @@ +import { PutObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; + +import { s3Client } from "../lib/s3.client.js"; + +export const generateUploadUrlService = async ( + folder: string, + fileName: string, + fileType: string +) => { + + const uniqueFileName = `${Date.now()}-${fileName}`; + + const key = `${folder}/${uniqueFileName}`; + + const command = new PutObjectCommand({ + Bucket: process.env.AWS_BUCKET_NAME!, + Key: key, + ContentType: fileType + }); + + const uploadUrl = await getSignedUrl(s3Client, command, { + expiresIn: 300 + }); + + const fileUrl = + `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`; + + return { + uploadUrl, + fileUrl + }; +}; + +interface MulterFile { + originalname: string; + buffer: Buffer; + mimetype: string; +} + +export const uploadFilesService = async ( + files: MulterFile[], + folder: string +): Promise => { + const uploadPromises = files.map(async (file) => { + const uniqueFileName = `${Date.now()}-${file.originalname}`; + const key = `${folder}/${uniqueFileName}`; + + const command = new PutObjectCommand({ + Bucket: process.env.AWS_BUCKET_NAME!, + Key: key, + Body: file.buffer, + ContentType: file.mimetype, + }); + + await s3Client.send(command); + + return `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`; + }); + + return Promise.all(uploadPromises); +}; \ No newline at end of file diff --git a/apps/core-api/src/services/notification.service.ts b/apps/core-api/src/services/notification.service.ts new file mode 100644 index 0000000..f4e2398 --- /dev/null +++ b/apps/core-api/src/services/notification.service.ts @@ -0,0 +1,87 @@ +import { Types } from "mongoose"; + +import { NotificationModel } from "../models/notification.model.js"; +import { PushSubscriptionModel } from "../models/push-subscription.model.js"; + +export interface PushSubscriptionInput { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; +} + +export class NotificationService { + // Push Subscription Methods + async saveSubscription(userId: string, subscription: PushSubscriptionInput) { + // Upsert subscription based on endpoint + const existing = await PushSubscriptionModel.findOne({ + endpoint: subscription.endpoint, + }); + + if (existing) { + if (existing.userId.toString() !== userId) { + // Re-assign to new user if endpoint exists but for a different user + existing.userId = userId as unknown as Types.ObjectId; + await existing.save(); + } + return existing; + } + + return await PushSubscriptionModel.create({ + userId, + endpoint: subscription.endpoint, + keys: subscription.keys, + }); + } + + async getUserSubscriptions(userId: string) { + return await PushSubscriptionModel.find({ userId }); + } + + async removeSubscription(endpoint: string) : Promise{ + await PushSubscriptionModel.deleteOne({ endpoint }); + } + + // Notification Methods + async createNotification( + userId: string, + title: string, + message: string, + type: string = "system", + metadata: Record = {} + ) { + return await NotificationModel.create({ + userId, + title, + message, + type, + metadata, + }); + } + + async getUserNotifications(userId: string, limit: number = 20) { + return await NotificationModel.find({ userId }) + .sort({ createdAt: -1 }) + .limit(limit); + } + + async markAsRead(notificationId: string, userId: string) { + return await NotificationModel.findOneAndUpdate( + { _id: notificationId, userId }, + { isRead: true }, + { new: true } + ); + } + + async markAllAsRead(userId: string) { + return await NotificationModel.updateMany( + { userId, isRead: false }, + { isRead: true } + ); + } + + async getUnreadCount(userId: string) { + return await NotificationModel.countDocuments({ userId, isRead: false }); + } +} diff --git a/apps/core-api/src/services/order.service.ts b/apps/core-api/src/services/order.service.ts new file mode 100644 index 0000000..bfce34a --- /dev/null +++ b/apps/core-api/src/services/order.service.ts @@ -0,0 +1,154 @@ +import { Types } from "mongoose"; + +import { publishEvent } from "../queue/publisher.js"; +import { ORDER_CREATED_EVENT } from "../queue/events.js"; +import { OrderModel } from "../models/order.model.js"; +import { GigModel } from "../models/gig.model.js"; +import { GigRequestModel } from "../models/gig-request.model.js"; +import { notificationQueue } from "../queue/notification.queue.js"; + +// ✅ Define input type +export interface CreateOrderInput { + gigId: string; + buyerId: string; + influencerId: string; + amount?: number; + connectionId: string; + dueDate?: string | undefined; +} + +// 🟢 CREATE ORDER +export const createOrderService = async (data: CreateOrderInput) => { + const { gigId, buyerId, influencerId, amount, connectionId, dueDate } = data; + + // 🔥 ID VALIDATION + if (!Types.ObjectId.isValid(gigId)) throw new Error(`Invalid Gig ID: ${gigId}`); + if (!Types.ObjectId.isValid(buyerId)) throw new Error(`Invalid Buyer ID: ${buyerId}`); + if (!Types.ObjectId.isValid(influencerId)) throw new Error(`Invalid Influencer ID: ${influencerId}`); + if (!Types.ObjectId.isValid(connectionId)) throw new Error(`Invalid Connection ID: ${connectionId}`); + + // 🔥 STEP 1: Validate gig request exists + const connection = await GigRequestModel.findById(new Types.ObjectId(connectionId)); + + if (!connection) { + throw new Error("Gig request not found"); + } + + // 🔥 STEP 2: Check gig request accepted + if (connection.status !== "accepted") { + throw new Error("Gig request not accepted"); + } + + // 🔥 STEP 3: Handle existing orders (Idempotency) + const existingOrderModel = await OrderModel.findOne({ + connectionId: new Types.ObjectId(connectionId), + gigId: new Types.ObjectId(gigId), + status: { $in: ["PENDING", "IN_ESCROW"] } + }); + + if (existingOrderModel) { + // If it's just PENDING, return it so the user can continue to payment + if (existingOrderModel.status === "PENDING") { + return { + orderId: existingOrderModel._id, + amount: existingOrderModel.amount, + alreadyExisted: true + }; + } + // If it's already in ESCROW, then we block (already paid) + throw new Error("You have already paid for this gig."); + } + + // 🔥 STEP 4: Fetch price from Gig (Security) + let orderAmount = amount; + if (!orderAmount) { + const gig = await GigModel.findById(new Types.ObjectId(gigId)); + if (!gig) throw new Error("Gig not found"); + orderAmount = gig.pricing.basePrice; + } + + // 🔥 STEP 5: Create order + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const orderData: any = { + gigId: new Types.ObjectId(gigId), + buyerId: new Types.ObjectId(buyerId), + influencerId: new Types.ObjectId(influencerId), + amount: orderAmount, + connectionId: new Types.ObjectId(connectionId), + status: "PENDING", + escrowStatus: "HOLD", + workStatus: "NOT_STARTED", + }; + + if (dueDate) { + orderData.dueDate = new Date(dueDate); + } + + const order = await OrderModel.create(orderData); + await publishEvent(ORDER_CREATED_EVENT, { + orderId: order._id.toString(), + buyerId: order.buyerId.toString(), + influencerId: order.influencerId.toString(), + amount: order.amount, + }); + + console.log("Adding ORDER_CREATED notification job", { userId: order.influencerId.toString(), orderId: order._id.toString() }); + + const job = await notificationQueue.add("new-notification", { + userId: order.influencerId.toString(), + type: "ORDER_CREATED", + title: "New Order", + message: "You have received a new order", + metadata: { + orderId: order._id.toString(), + senderId: order.buyerId.toString() + } + }); + + console.log("Notification job added", job.id); + + console.log("🚀 order.created event published and notification queued"); + return { + orderId: order._id, + amount: order.amount, + }; +}; + +// ================= SUBMIT WORK ================= +export const submitWorkService = async (orderId: string) => { + const order = await OrderModel.findById(new Types.ObjectId(orderId)); + + if (!order) { + throw new Error("OrderModel not found"); + } + + if (order.status !== "IN_ESCROW") { + throw new Error("Payment not completed"); + } + + order.workStatus = "SUBMITTED"; + await order.save(); + + return order; +}; + +// ================= APPROVE WORK ================= +export const approveWorkService = async (orderId: string) => { + const order = await OrderModel.findById(new Types.ObjectId(orderId)); + + if (!order) { + throw new Error("OrderModel not found"); + } + + if (order.workStatus !== "SUBMITTED") { + throw new Error("Work not submitted"); + } + + order.workStatus = "APPROVED"; + order.status = "COMPLETED"; + order.escrowStatus = "RELEASED"; + + await order.save(); + + return order; +}; \ No newline at end of file diff --git a/apps/core-api/src/services/payment.service.ts b/apps/core-api/src/services/payment.service.ts new file mode 100644 index 0000000..6d7d801 --- /dev/null +++ b/apps/core-api/src/services/payment.service.ts @@ -0,0 +1,51 @@ +import { stripe } from "../lib/stripe.js"; + +export const createCheckoutSession = async ( + amount: number, + influencerStripeAccountId: string, + metadata: Record +) => { + // If no Stripe API key is configured, return a mock redirect URL + if (!process.env.STRIPE_SECRET_KEY) { + return { url: "https://buy.stripe.com/test_mock" }; + } + + const PLATFORM_FEE_PERCENTAGE = 0.10; // 10% + const platformFeeAmount = Math.round(amount * PLATFORM_FEE_PERCENTAGE * 100); // In cents + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sessionConfig: any = { + payment_method_types: ["card"], + mode: "payment", + + line_items: [ + { + price_data: { + currency: "inr", + product_data: { + name: "Influencer Booking", + }, + unit_amount: Math.round(amount * 100), + }, + quantity: 1, + }, + ], + + success_url: `http://localhost:3000/payment/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: "http://localhost:3000/payment/cancel", + metadata, + }; + + // Skip Stripe Connect destination split if mock payout is enabled! + if (process.env.STRIPE_MOCK_PAYOUT !== "true" && influencerStripeAccountId) { + sessionConfig.payment_intent_data = { + application_fee_amount: platformFeeAmount, + transfer_data: { + destination: influencerStripeAccountId, + }, + }; + } + + const session = await stripe.checkout.sessions.create(sessionConfig); + return session; +}; \ No newline at end of file diff --git a/apps/core-api/src/services/profile.service.ts b/apps/core-api/src/services/profile.service.ts new file mode 100644 index 0000000..270101f --- /dev/null +++ b/apps/core-api/src/services/profile.service.ts @@ -0,0 +1,89 @@ +import { profileRepository } from "../repositories/profile.repository.js"; +import { userRepository } from "../repositories/user.repository.js"; +import type { HttpError } from "../modules/auth/http-error.js"; +import type { IInfluencerProfile } from "../models/influencer.model.js"; +import type { IBrandProfile } from "../models/brand.model.js"; +import { findGigsByInfluencer } from "../repositories/gig.repository.js"; + + +export const getMyProfileService = async (userId: string) => { + + const user = await userRepository.findById(userId); + + if (!user) { + const err: HttpError = new Error("User not found"); + err.statusCode = 404; + throw err; + } + + if (user.role === "INFLUENCER") { + + const profile = await profileRepository.findInfluencerByUserId(userId); + + if (!profile) { + const err: HttpError = new Error("Profile not found"); + err.statusCode = 404; + throw err; + } + + + return profile; + } + + if (user.role === "BRAND") { + + const profile = await profileRepository.findBrandByUserId(userId); + + if (!profile) { + const err: HttpError = new Error("Profile not found"); + err.statusCode = 404; + throw err; + } + + + return profile; + } + + const err: HttpError = new Error("Invalid role"); + err.statusCode = 400; + throw err; +}; + + +export const updateProfileService = async ( + userId: string, + data: unknown + +) => { + + const user = await userRepository.findById(userId); + if (!user) { + const err: HttpError = new Error("User not found"); + err.statusCode = 404; + throw err; + } + + if (user.role === "INFLUENCER") { + return profileRepository.updateInfluencer(userId, data as Partial); + } + + if (user.role === "BRAND") { + return profileRepository.updateBrand(userId, data as Partial); + } + + throw new Error("Invalid role"); +}; + +export const getPublicInfluencerProfileService = async (influencerId: string) => { + const profile = await profileRepository.findInfluencerById(influencerId); + + if (!profile) { + const err: HttpError = new Error("Influencer profile not found"); + err.statusCode = 404; + throw err; + } + + const gigs = await findGigsByInfluencer(influencerId); + + return { profile, gigs }; +}; diff --git a/apps/core-api/src/services/report.service.ts b/apps/core-api/src/services/report.service.ts new file mode 100644 index 0000000..adecadb --- /dev/null +++ b/apps/core-api/src/services/report.service.ts @@ -0,0 +1,302 @@ +import { Types } from "mongoose"; + +import { ReportModel } from "../models/report.model.js"; +import { GigModel } from "../models/gig.model.js"; +import { OrderModel } from "../models/order.model.js"; +import type { JwtPayload } from "../modules/auth/auth.utils.js"; +import type { ReportDocument } from "../types/report.types.js"; +import { createReportRepository, findOneReportRepository } from "../repositories/report.repository.js"; + +export const createReportService = async ( + user: JwtPayload, + data: ReportDocument +) => { + const reportId = `REP-${Date.now()}`; + + let usersInvolved: Types.ObjectId[] = []; + + if (data.entityType === "ORDER") { + const order = await OrderModel.findById(data.entityId); + + if (!order) { + throw new Error("Order not found"); + } + + const userId = user.userId.toString(); + + if ( + order.buyerId.toString() !== userId && + order.influencerId.toString() !== userId + ) { + throw new Error("Not allowed to report this order"); + } + + const existingReport = await findOneReportRepository({ + entityType: "ORDER", + entityId: data.entityId, + reportedBy: user.userId + }); + + if (existingReport) { + throw new Error("You already reported this order"); + } + + if (data.type !== "PAYMENT") { + throw new Error("Invalid report type for order"); + } + + if (!data.description) { + throw new Error("Description is required for payment reports"); + } + + usersInvolved = [ + order.buyerId, + order.influencerId + ]; + } + + if (data.entityType === "GIG") { + const gig = await GigModel.findById(data.entityId); + + if (!gig) { + throw new Error("Gig not found"); + } + + usersInvolved = [ + gig.primaryInfluencerId + ]; + + if (gig.primaryInfluencerId.toString() === user.userId) { + throw new Error("You cannot report your own gig"); + } + } + + const report = await createReportRepository({ + reportId, + entityType: data.entityType, + entityId: new Types.ObjectId(data.entityId), + type: data.type, + reportedBy: new Types.ObjectId(user.userId), + usersInvolved, + ...(data.subType && { subType: data.subType }), + ...(data.description && { description: data.description }), + evidenceUrls: data.evidenceUrls ?? [], + auditTrail: [ + { + action: "REPORT_CREATED", + performedBy: new Types.ObjectId(user.userId), + createdAt: new Date() + } + ] + }); + + if (data.entityType === "GIG") { + await GigModel.updateOne( + { _id: data.entityId }, + { $inc: { reportCount: 1 } }, + + ); + + const updatedGig = await GigModel.findById(data.entityId); + + if ( + updatedGig && + updatedGig.reportCount >= 3 && + updatedGig.status === "published" + ) { + await GigModel.updateOne( + { _id: data.entityId }, + { $set: { status: "flagged" } } + ); + } + } + + return report; +}; + + +export const updateReportStatusService = async ( + adminId: string, + reportId: string +) => { + const report = await ReportModel.findById(reportId); + + if (!report) { + throw new Error("Report not found"); + } + + // Prevent invalid transitions + if (report.status === "RESOLVED") { + throw new Error("Cannot update a resolved report"); + } + + if (report.status !== "PENDING") { + throw new Error("Only PENDING reports can move to UNDER_REVIEW"); + } + + // Update status + report.status = "UNDER_REVIEW"; + + //Audit trail + report.auditTrail.push({ + action: "STATUS_UPDATED_TO_UNDER_REVIEW", + performedBy: new Types.ObjectId(adminId), + createdAt: new Date() + }); + + await report.save(); + + return report; +}; + +//===================RESOLVE REPORT SERVICE=================== + +export const resolveReportService = async ( + adminId: string, + reportId: string, + resolution: "VALID" | "INVALID", + adminNotes?: string +) => { + const report = await ReportModel.findById(reportId); + + if (!report) { + throw new Error("Report not found"); + } + + // ❗ Only allow proper flow + if (report.status !== "UNDER_REVIEW") { + throw new Error("Report must be UNDER_REVIEW to resolve"); + } + + // ❗ Validate resolution input + if (!["VALID", "INVALID"].includes(resolution)) { + throw new Error("Invalid resolution type"); + } + + // ✅ Apply resolution + report.resolution = resolution; + report.status = "RESOLVED"; + if (adminNotes) report.adminNotes = adminNotes; + + // ========================= + // 🔥 ENTITY EFFECTS + // ========================= + + if (report.entityType === "GIG") { + const gig = await GigModel.findById(report.entityId); + + if (!gig) throw new Error("Gig not found"); + + if (resolution === "VALID") { + // Keep flagged (or enforce flag) + if (gig.status !== "flagged") { + gig.status = "flagged"; + } + } + + if (resolution === "INVALID") { + // Restore gig + gig.status = "published"; + + // Optional: reduce report count + gig.reportCount = Math.max(0, gig.reportCount - 1); + } + + await gig.save(); + } + + if (report.entityType === "ORDER") { + if (resolution === "VALID") { + const order = await OrderModel.findById(report.entityId); + + if (!order) throw new Error("Order not found"); + + order.status = "DISPUTED"; + await order.save(); + } + } + + // ========================= + // 📜 AUDIT TRAIL + // ========================= + + report.auditTrail.push({ + action: `RESOLVED_${resolution}`, + performedBy: new Types.ObjectId(adminId), + createdAt: new Date() + }); + + await report.save(); + + return report; +}; + + +//=============GET REPORTS SERVICE ============= + +export const getReportsService = async (filters: { + status?: string; + entityType?: string; +}) => { + const query: { status?: string; entityType?: string } = {}; + + // ✅ Filter by status + if (filters.status) { + query.status = filters.status; + } + + // ✅ Filter by entity type + if (filters.entityType) { + query.entityType = filters.entityType; + } + + const reports = await ReportModel.find(query) + .sort({ createdAt: -1 }) + .populate("reportedBy", "name email") + .populate("usersInvolved", "name email") + .lean(); + + return reports; +}; + +//=====================GET REPORT BY ID==================== + +export const getReportByIdService = async (reportId: string) => { + const report = await ReportModel.findById(reportId) + .populate("reportedBy", "name email") + .populate("usersInvolved", "name email") + .lean(); + + if (!report) { + throw new Error("Report not found"); + } + + let entityDetails: unknown = null; + + // 🔍 Attach entity info (this is the key upgrade) + if (report.entityType === "GIG") { + const gig = await GigModel.findById(report.entityId) + .select("title status reportCount primaryInfluencerId") + .populate("primaryInfluencerId", "name email") + .lean(); + + entityDetails = gig; + } + + if (report.entityType === "ORDER") { + const order = await OrderModel.findById(report.entityId) + .select("status buyerId influencerId") + .populate("buyerId", "name email") + .populate("influencerId", "name email") + .lean(); + + entityDetails = order; + } + + return { + ...report, + entityDetails + }; +}; + + diff --git a/apps/core-api/src/services/verification.service.ts b/apps/core-api/src/services/verification.service.ts new file mode 100644 index 0000000..5ea6b65 --- /dev/null +++ b/apps/core-api/src/services/verification.service.ts @@ -0,0 +1,302 @@ +import bcrypt from "bcrypt"; + +import type { HttpError } from "../modules/auth/http-error.js"; +import { pendingSignupRepository } from "../repositories/Signup.repository.js"; +import { userRepository } from "../repositories/user.repository.js"; +import { profileRepository } from "../repositories/profile.repository.js"; +import type { PendingSignupFilter, PendingSignupQuery } from "../types/pendingSignup.types.js"; +import { getChannel } from "../queue/rabbit.js"; +import { INFLUENCER_CREATED_EVENT } from "../queue/events.js"; +import { BRAND_CREATED_EVENT } from "../queue/events.js"; + +// ================= GET PENDING SIGNUP COUNT ================= +export const getTotalUsersService = async () => { + const userCount = await userRepository.findAllUsers(); + return userCount.length; +}; + + + +// ================= VERIFY OTP ================= +export const verifyOtpService = async ( + email: string, + otp: string +) => { + const pending = await pendingSignupRepository.findByEmail(email); + + if (!pending) { + const err: HttpError = new Error("Signup request not found"); + err.statusCode = 404; + throw err; + } + + if (pending.isEmailVerified) { + const err: HttpError = new Error("Email already verified"); + err.statusCode = 400; + throw err; + } + + const now = new Date(); + + // 🔒 Lock check + if (pending.otpLockedUntil && pending.otpLockedUntil > now) { + const err: HttpError = new Error("Too many attempts. Try again later."); + err.statusCode = 403; + throw err; + } + + // ⏳ Expiry check + if (!pending.emailOtpExpiresAt || pending.emailOtpExpiresAt < now) { + const err: HttpError = new Error("OTP expired"); + err.statusCode = 400; + throw err; + } + + const isMatch = await bcrypt.compare( + otp, + pending.emailOtpHash as string + ); + + if (!isMatch) { + pending.otpAttempts = (pending.otpAttempts || 0) + 1; + + if (pending.otpAttempts >= 5) { + pending.otpLockedUntil = new Date( + now.getTime() + 15 * 60 * 1000 // lock for 15 minutes + ); + } + + await pending.save(); + + const err: HttpError = new Error("Invalid OTP"); + err.statusCode = 401; + throw err; + } + + // ✅ SUCCESS + pending.isEmailVerified = true; + pending.emailOtpHash = null; + pending.emailOtpExpiresAt = null; + pending.otpAttempts = 0; + pending.otpLockedUntil = null; + + await pending.save(); + + return { message: "Email verified successfully" }; +}; + +// ================= RESEND OTP ================= +export const resendOtpService = async (email: string) => { + const pending = await pendingSignupRepository.findByEmail(email); + + if (!pending) { + const err: HttpError = new Error("Signup request not found"); + err.statusCode = 404; + throw err; + } + + if (pending.isEmailVerified) { + const err: HttpError = new Error("Email already verified"); + err.statusCode = 400; + throw err; + } + + const now = new Date(); + + // ⏳ Cooldown check (60 seconds) + if ( + pending.otpLastSentAt && + now.getTime() - pending.otpLastSentAt.getTime() < 60 * 1000 + ) { + const err: HttpError = new Error("Please wait before requesting another OTP"); + err.statusCode = 429; + throw err; + } + + // 🔁 Max resend limit + if ((pending.otpResendCount || 0) >= 5) { + const err: HttpError = new Error("Maximum resend attempts reached"); + err.statusCode = 403; + throw err; + } + + // 🔐 Generate new OTP + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + const hashedOtp = await bcrypt.hash(otp, 10); + + pending.emailOtpHash = hashedOtp; + pending.emailOtpExpiresAt = new Date(now.getTime() + 5 * 60 * 1000); + pending.otpResendCount = (pending.otpResendCount || 0) + 1; + pending.otpLastSentAt = now; + + await pending.save(); + + // TODO: Send OTP email here + // await sendOtpEmail(email, otp); + + return { message: "OTP resent successfully" }; +}; + +// ================= APPROVE SIGNUP ================= +export const approveSignupService = async (email: string) => { + const pending = await pendingSignupRepository.findByEmail(email); + + if (!pending) { + const err: HttpError = new Error("Pending signup not found"); + err.statusCode = 404; + throw err; + } + + if (pending.status !== "PENDING") { + const err: HttpError = new Error("Signup already processed"); + err.statusCode = 400; + throw err; + } + + // Check if user already exists + const existingUser = await userRepository.findByEmail(pending.email); + if (existingUser) { + const err: HttpError = new Error("User with this email already exists"); + err.statusCode = 409; + throw err; + } + + const user = await userRepository.create({ + email: pending.email, + password: pending.passwordHash, + role: pending.role, + // @ts-expect-error - adminLevel not in user create type yet + adminLevel: pending.adminLevel || null, + isEmailVerified: true, + status: "ACTIVE", + }); + +if (user.role === "INFLUENCER") { + const influencer = await profileRepository.createInfluencer({ + userId: user._id, + fullName: pending.fullName, + username: pending.profileData?.username || user.email?.split("@")[0] || user.email, + bio: pending.profileData?.bio || "", + location: pending.profileData?.location || "", + profileImageUrl: pending.profileData?.profileImageUrl || "", + instagramUrl: pending.profileData?.socialLinks?.instagram || "", + youtubeUrl: pending.profileData?.socialLinks?.youtube || "", + tiktokUrl: pending.profileData?.socialLinks?.tiktok || "", + categories: pending.profileData?.niche ? [pending.profileData.niche] : [], + languages: [], + isProfileComplete: true, // Since they just did the setup + isVerified: false, + }); + + // Send event AFTER creation + getChannel().sendToQueue( + INFLUENCER_CREATED_EVENT, + Buffer.from( + JSON.stringify({ + id: influencer._id.toString(), + fullName: influencer.fullName, + username: influencer.username, + instagram: influencer.instagramUrl, + youtube: influencer.youtubeUrl, + category: influencer.categories, + location: influencer.location, + languages: influencer.languages, + followersCount: 0, + engagementRate: 0, + }) + ), + { persistent: true } + ); +} + + if (user.role === "BRAND") { + const brand = await profileRepository.createBrand({ + userId: user._id, + companyName: pending.profileData?.companyName || pending.fullName, + industry: pending.profileData?.industry || "Not Specified", + website: pending.profileData?.website || "", + profileImageUrl: pending.profileData?.profileImageUrl || "", + contactPersonName: pending.fullName, + contactEmail: user.email, + companySize: pending.profileData?.companySize || "", + description: pending.profileData?.bio || "", + headquarters: pending.profileData?.location || "", + documents: [], + isProfileComplete: true, + isVerified: false, + }); + + getChannel().sendToQueue( + BRAND_CREATED_EVENT, + Buffer.from( + JSON.stringify({ + id: brand._id.toString(), + companyName: brand.companyName, + industry: brand.industry, + contactPersonName: brand.contactPersonName, + }) + ), + { persistent: true } + ); +} + + await pendingSignupRepository.updateStatus(email, "APPROVED"); + + try { + getChannel().sendToQueue( + "user.approved", + Buffer.from(JSON.stringify({ userId: user._id.toString(), email: user.email, fullName: pending.fullName, role: user.role })), + { persistent: true } + ); + } catch (err) { + // ignore + } + + return { message: "Signup approved successfully" }; +}; + +// ================= REJECT SIGNUP ================= +export const rejectSignupService = async ( + email: string, + _reason?: string +) => { + const pending = await pendingSignupRepository.findByEmail(email); + + if (!pending) { + const err: HttpError = new Error("Pending signup not found"); + err.statusCode = 404; + throw err; + } + + await pendingSignupRepository.updateStatus(email, "REJECTED"); + + return { message: "Signup rejected successfully" }; +}; + +export const cleanupExpiredSignups = async () => { + const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000); + + await pendingSignupRepository.deleteMany({ + isEmailVerified: false, + createdAt: { $lt: cutoff }, + }); +}; + + +export const getAllPendingSignupService = async (query: PendingSignupQuery = {}) => { + const { search, role, status = "PENDING" } = query; + const filter: PendingSignupFilter = { status }; + + if (role) { + filter.role = role.toUpperCase(); + } + + if (search) { + filter.$or = [ + { email: { $regex: search, $options: "i" } }, + { documents: { $regex: search, $options: "i" } }, + ]; + } + + return pendingSignupRepository.getAllPendingSignups(filter); +}; \ No newline at end of file diff --git a/apps/core-api/src/types/availability.types.ts b/apps/core-api/src/types/availability.types.ts new file mode 100644 index 0000000..44417f2 --- /dev/null +++ b/apps/core-api/src/types/availability.types.ts @@ -0,0 +1,40 @@ +import { Types } from "mongoose"; + +export type Weekday = + | "monday" + | "tuesday" + | "wednesday" + | "thursday" + | "friday" + | "saturday" + | "sunday"; + +export interface TimeSlot { + startTime: string; + endTime: string; +} + +export interface WeeklyRule { + day: Weekday; + isEnabled: boolean; + slots: TimeSlot[]; +} + +export interface DateOverride { + date: string; + isAvailable: boolean; + slots: TimeSlot[]; +} + +export interface AvailabilityDocument { + influencerProfileId: Types.ObjectId; + + timezone: string; + + weeklyRules: WeeklyRule[]; + + dateOverrides: DateOverride[]; + + createdAt: Date; + updatedAt: Date; +} \ No newline at end of file diff --git a/apps/core-api/src/types/collaboration.type.ts b/apps/core-api/src/types/collaboration.type.ts new file mode 100644 index 0000000..bf92100 --- /dev/null +++ b/apps/core-api/src/types/collaboration.type.ts @@ -0,0 +1,22 @@ +import { Types } from "mongoose"; + +export type CollaborationStatus = + | "pending" + | "accepted" + | "rejected" + | "cancelled"; + +export interface GigCollaborationDocument { + gigId: Types.ObjectId; + + primaryInfluencerId: Types.ObjectId; + + invitedInfluencerId: Types.ObjectId; + + status: CollaborationStatus; + + respondedAt?: Date; + + createdAt: Date; + updatedAt: Date; +} diff --git a/apps/core-api/src/types/express.d.ts b/apps/core-api/src/types/express.d.ts new file mode 100644 index 0000000..89e7d9f --- /dev/null +++ b/apps/core-api/src/types/express.d.ts @@ -0,0 +1,11 @@ +import type { JwtPayload } from "../modules/auth/auth.utils.ts"; + +declare global { + namespace Express { + interface Request { + user?: JwtPayload; + } + } +} + +export {}; diff --git a/apps/core-api/src/types/gig.type.ts b/apps/core-api/src/types/gig.type.ts new file mode 100644 index 0000000..5cd152c --- /dev/null +++ b/apps/core-api/src/types/gig.type.ts @@ -0,0 +1,85 @@ +import { Types } from "mongoose"; + +export type GigStatus = + | "draft" + | "published" + | "flagged" + | "paused" + | "under_review" + | "rejected" + | "archived"; + +export type Platform = + | "instagram" + | "youtube" + | "tiktok"; + +export type GigType = + | "solo" + | "collaboration"; + +export interface GigDeliverable { + contentType: string; + quantity: number; + includedItems: string[]; +} + +export interface GigPricing { + basePrice: number; + currency: "INR" | "USD"; + negotiationAllowed: boolean; + deliveryTimeInDays: number; + revisionsIncluded: number; +} + +export interface GigReport { + reportedBy: Types.ObjectId; + type: "SPAM" | "MISLEADING" | "INAPPROPRIATE" | "COPYRIGHT"; + message?: string; + resolved: boolean; + createdAt: Date; +} + +export interface GigModerationLog { + action: "PAUSED" | "REJECTED" | "IGNORED"; + reason?: string; + adminId: Types.ObjectId; + createdAt: Date; +} + +export interface GigDocument { + _id: Types.ObjectId; + title: string; + shortDescription: string; + + platform: Platform; + gigType: GigType; + + influencerIds: Types.ObjectId[]; + primaryInfluencerId: Types.ObjectId; + + category: string; + tags: string[]; + + deliverables: GigDeliverable[]; + + pricing: GigPricing; + + maxBookingsPerSlot?: number; + + status: GigStatus; + + isDeleted: boolean; + reportCount: number; + reports?: GigReport[]; + moderationLogs?: GigModerationLog[]; + + bannerUrl?: string; + createdAt: Date; + updatedAt: Date; +} + +export type CreateGigDBInput = Omit< + GigDocument, + "_id" | "createdAt" | "updatedAt" +>; \ No newline at end of file diff --git a/apps/core-api/src/types/order.types.ts b/apps/core-api/src/types/order.types.ts new file mode 100644 index 0000000..9513268 --- /dev/null +++ b/apps/core-api/src/types/order.types.ts @@ -0,0 +1,32 @@ +import { Types } from "mongoose"; + +export interface OrderDocument { + _id: Types.ObjectId; + buyerId: Types.ObjectId; + influencerId: Types.ObjectId; + gigId: Types.ObjectId; + connectionId: Types.ObjectId; + dueDate?: Date; + + amount: number; + currency: string; + + status: "PENDING" | "IN_ESCROW" | "COMPLETED" | "CANCELLED" | "DISPUTED"; + escrowStatus: "HOLD" | "RELEASED"; + workStatus: "NOT_STARTED" | "SUBMITTED" | "APPROVED" | "REJECTED"; + deliverableUrl?: string; + rejectionNote?: string; + + stripePaymentIntentId?: string; + platformFee?: number; + influencerAmount?: number; + + payoutStatus: "HOLD" | "AVAILABLE" | "PROCESSING" | "PAID"; + availableAt?: Date; + withdrawRequestedAt?: Date; + releaseAt?: Date; + stripePayoutId?: string; + + createdAt: Date; + updatedAt: Date; +} diff --git a/apps/core-api/src/types/pendingSignup.types.ts b/apps/core-api/src/types/pendingSignup.types.ts new file mode 100644 index 0000000..3346ec2 --- /dev/null +++ b/apps/core-api/src/types/pendingSignup.types.ts @@ -0,0 +1,15 @@ +export interface PendingSignupQuery{ + search?:string; + role?:string; + status?:string; + +} + +export interface PendingSignupFilter{ + status?:string; + role?:string; + $or?:Array<{ + email?:{$regex:string,$options:string}; + documents?:{$regex:string,$options:string}; + }>; +} \ No newline at end of file diff --git a/apps/core-api/src/types/report.types.ts b/apps/core-api/src/types/report.types.ts new file mode 100644 index 0000000..122a7ed --- /dev/null +++ b/apps/core-api/src/types/report.types.ts @@ -0,0 +1,25 @@ +import { type Document, Types } from "mongoose"; + +export interface AuditTrailEntry { + action: string; + performedBy: Types.ObjectId; + createdAt: Date; +} + +export interface ReportDocument extends Document { + reportId: string; + entityType: "GIG" | "ORDER" | "USER"; + entityId: Types.ObjectId; + type: "CONTENT" | "PAYMENT" | "BEHAVIOR"; + subType?: "NOT_RECEIVED" | "LOW_QUALITY" | "SCAM" | "PAYMENT_ISSUE"; + reportedBy: Types.ObjectId; + usersInvolved: Types.ObjectId[]; + description?: string; + status: "PENDING" | "UNDER_REVIEW" | "RESOLVED"; + resolution?: "VALID" | "INVALID"; + adminNotes?: string; + auditTrail: AuditTrailEntry[]; + evidenceUrls?: string[]; + createdAt: Date; + updatedAt: Date; +} diff --git a/apps/core-api/src/utils/sendotpEmail.ts b/apps/core-api/src/utils/sendotpEmail.ts new file mode 100644 index 0000000..c3cab09 --- /dev/null +++ b/apps/core-api/src/utils/sendotpEmail.ts @@ -0,0 +1,36 @@ +import nodemailer from "nodemailer"; + +export const sendOtpEmail = async (to: string,otp: string) => { + const transporter = nodemailer.createTransport({ + service: "gmail", + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, + }); + + await transporter.sendMail({ + from: process.env.EMAIL_USER, + to, + subject: "Your OTP Code", + text: `Your verification OTP is: ${otp}`, + }); + +}; + +export const sendTransactionalEmail = async (to: string, subject: string, text: string) => { + const transporter = nodemailer.createTransport({ + service: "gmail", + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, + }); + + await transporter.sendMail({ + from: process.env.EMAIL_USER, + to, + subject, + text, + }); +}; diff --git a/apps/core-api/tsconfig.json b/apps/core-api/tsconfig.json index ccbde0b..6a749c2 100644 --- a/apps/core-api/tsconfig.json +++ b/apps/core-api/tsconfig.json @@ -1,46 +1,31 @@ { - // Visit https://aka.ms/tsconfig to read more about this file "compilerOptions": { - // File Layout - // "rootDir": "./src", "outDir": "./dist", - // Environment Settings - // See also https://aka.ms/tsconfig/module "module": "nodenext", "target": "esnext", "types": ["node"], - // For nodejs: - // "lib": ["esnext"], - // "types": ["node"], - // and npm install -D @types/node - // Other Outputs "sourceMap": true, "declaration": true, "declarationMap": true, - // Stricter Typechecking Options "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, - // Style Options - // "noImplicitReturns": true, - // "noImplicitOverride": true, - // "noUnusedLocals": true, - // "noUnusedParameters": true, - // "noFallthroughCasesInSwitch": true, - // "noPropertyAccessFromIndexSignature": true, - - // Recommended Options "strict": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, - // "jsx": "react-jsx", "verbatimModuleSyntax": true, "isolatedModules": true, "noUncheckedSideEffectImports": true, "moduleDetection": "force", - "skipLibCheck": true + "skipLibCheck": true, + + // 🔥 ADD THESE + "baseUrl": ".", + "paths": { + "@shared/*": ["../shared/*"] + } } } diff --git a/apps/realtime/package.json b/apps/realtime/package.json index 39f0266..1c537b5 100644 --- a/apps/realtime/package.json +++ b/apps/realtime/package.json @@ -14,12 +14,19 @@ "license": "ISC", "packageManager": "pnpm@10.27.0", "dependencies": { + "amqplib": "^0.10.9", + "cors": "^2.8.6", + "dotenv": "^17.2.3", "express": "^5.2.1", + "ioredis": "^5.10.1", + "mongoose": "^9.1.5", "morgan": "^1.10.1", "socket.io": "^4.8.3", "winston": "^3.19.0" }, "devDependencies": { + "@types/amqplib": "^0.10.8", + "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/morgan": "^1.9.10", "@types/node": "^25.0.10", diff --git a/apps/realtime/src/db/db.ts b/apps/realtime/src/db/db.ts new file mode 100644 index 0000000..37187e3 --- /dev/null +++ b/apps/realtime/src/db/db.ts @@ -0,0 +1,13 @@ +import mongoose from "mongoose"; + +export const connectDB = async () => { + const uri = process.env.MONGO_URI; + + if (!uri) { + throw new Error("MONGO_URI not defined"); + } + + await mongoose.connect(uri); + + console.log("Realtime DB connected"); +}; \ No newline at end of file diff --git a/apps/realtime/src/server.ts b/apps/realtime/src/server.ts index 8dd1d2c..d051064 100644 --- a/apps/realtime/src/server.ts +++ b/apps/realtime/src/server.ts @@ -1,13 +1,22 @@ -import { createServer } from "http"; + +import dotenv from "dotenv" + +dotenv.config() +import { createServer } from "http"; import express from "express"; -import { Server } from "socket.io"; +import cors from "cors"; +import { Server } from "socket.io"; +import amqp from "amqplib"; +import {connectDB} from "./db/db" import { httpLogger } from "./middlewares/httpLogger"; import { logger } from "./utils/logger"; import { errorHandler, notFound } from "./middlewares/errorHandler"; +import { registerChatHandlers } from "./sockets/chat.socket"; const app = express(); +app.use(cors()) app.use(express.json()) app.use(httpLogger) const httpServer = createServer(app); @@ -27,14 +36,78 @@ app.get("/health", (req, res) => { io.on("connection", (socket) => { logger.info("Socket connected:", socket.id); - + registerChatHandlers(io, socket); socket.on("disconnect", () => { logger.info("Socket disconnected:", socket.id); }); }); + +app.post('/internal/emit', (req, res) => { + const secret = req.headers['x-internal-secret']; + if (!secret || secret !== process.env.INTERNAL_SECRET) { + return res.status(403).json({ error: 'Forbidden: Invalid internal secret' }); + } + + const { event, room, payload } = req.body; + if (!event || !room || !payload) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + io.to(room).emit(event, payload); + logger.info(`Emitted internal event to room `); + res.status(200).json({ success: true }); +}); + app.use(notFound) app.use(errorHandler) const PORT = Number(process.env.PORT) || 6001; -httpServer.listen(PORT, "127.0.0.1",() => { - logger.info(`Realtime service running at http://localhost:${PORT}`); -}); +const startServer = async () => { + try { + await connectDB(); // ✅ FIRST + + const rabbitConn = await amqp.connect(process.env.RABBIT_URL || "amqp://localhost:5672"); + const rabbitChannel = await rabbitConn.createChannel(); + await rabbitChannel.assertQueue("notification.events", { durable: true }); + rabbitChannel.consume("notification.events", (msg) => { + if (msg !== null) { + try { + const { event, room, payload } = JSON.parse(msg.content.toString()); + if (event && room && payload) { + io.to(room).emit(event, payload); + logger.info(`Emitted ${event} from RabbitMQ to room ${room}`); + } + } catch (err) { + logger.error("Failed to process rabbitmq message", err); + } + rabbitChannel.ack(msg); + } + }); + + await rabbitChannel.assertQueue("notification.created", { durable: true }); + rabbitChannel.consume("notification.created", (msg) => { + if (msg !== null) { + try { + const notification = JSON.parse(msg.content.toString()); + const userId = notification.userId || notification.receiverId; + if (userId) { + io.to(`user:${userId}`).emit("notification:new", notification); + logger.info(`Emitted notification:new from notification.created queue to room user:${userId}`); + } + } catch (err) { + logger.error("Failed to process notification.created message", err); + } + rabbitChannel.ack(msg); + } + }); + + httpServer.listen(PORT, "0.0.0.0", () => { + logger.info(`Realtime service running at http://localhost:${PORT}`); + }); + + } catch (error) { + console.error("Failed to start realtime server:", error); + process.exit(1); + } +}; + +startServer(); \ No newline at end of file diff --git a/apps/realtime/src/services/connection.service.ts b/apps/realtime/src/services/connection.service.ts new file mode 100644 index 0000000..893e9a8 --- /dev/null +++ b/apps/realtime/src/services/connection.service.ts @@ -0,0 +1,64 @@ +/** + * connection.service.ts (Realtime Service) + * + * Manages the in-memory registry of which users are currently online + * and which socket IDs they have open (multiple tabs = multiple socket IDs). + * + * Why in-memory? Socket presence is ephemeral by nature. + * We don't need to persist this to DB — it resets when the server restarts. + * + * Data shape: + * onlineUsers = Map { + * "userId123" => ["socketId_tab1", "socketId_tab2"], + * "userId456" => ["socketId_tab1"], + * } + */ + +// userId → array of active socket IDs (one per open browser tab) +const onlineUsers = new Map(); + +/** + * Register a new socket connection for a user. + * Called when a socket connects and sends their userId. + */ +export const registerUserSocket = (userId: string, socketId: string): void => { + const existing = onlineUsers.get(userId) ?? []; + if (!existing.includes(socketId)) { + existing.push(socketId); + } + onlineUsers.set(userId, existing); +}; + +/** + * Remove a specific socket from a user's connection list. + * Called on socket disconnect. If no sockets remain, the user is offline. + */ +export const removeUserSocket = (userId: string, socketId: string): void => { + const existing = onlineUsers.get(userId) ?? []; + const updated = existing.filter((id) => id !== socketId); + + if (updated.length === 0) { + onlineUsers.delete(userId); // fully offline + } else { + onlineUsers.set(userId, updated); + } +}; + +/** + * Check if a user has at least one active socket connection. + */ +export const isUserOnline = (userId: string): boolean => + (onlineUsers.get(userId)?.length ?? 0) > 0; + +/** + * Get all active socket IDs for a user. + */ +export const getUserSockets = (userId: string): string[] => + onlineUsers.get(userId) ?? []; + +/** + * Get a snapshot of all currently online user IDs. + * Useful for debugging or presence broadcasts. + */ +export const getOnlineUserIds = (): string[] => + Array.from(onlineUsers.keys()); diff --git a/apps/realtime/src/services/emit.service.ts b/apps/realtime/src/services/emit.service.ts new file mode 100644 index 0000000..4f5c778 --- /dev/null +++ b/apps/realtime/src/services/emit.service.ts @@ -0,0 +1,86 @@ +import { Server } from "socket.io"; + +import { getUserRoom, getConversationRoom } from "../utils/room.utils.js"; + +/** + * emit.service.ts + * + * All outgoing socket broadcasts are handled here. + * Instead of calling io.to(...).emit(...) scattered everywhere, + * we have named functions that read like plain English. + * + * Why? Makes it easy to trace exactly what events exist, + * what data they carry, and when they fire. + */ + +/** + * Deliver a saved message to both participants in real-time. + * + * Emits to: + * - receiver's personal room (the other person's screen) + * - sender's personal room (so their other open tabs update) + * - conversation room (so any future listeners on the chat room also get it) + */ +export const emitNewMessage = ( + io: Server, + message: { + _id: string; + gigRequestId: string; + senderId: string; + receiverId: string; + content: string; + status: string; + createdAt: Date | string; + } +): void => { + const receiverRoom = getUserRoom(message.receiverId); + const senderRoom = getUserRoom(message.senderId); + const convRoom = getConversationRoom(message.gigRequestId.toString()); + + // Push to receiver's screen + io.to(receiverRoom).emit("receive_message", message); + + // Push to sender's other tabs (prevents duplicate via optimistic UI) + io.to(senderRoom).emit("receive_message", message); + + // Push to conversation room (for future multi-device support) + io.to(convRoom).emit("receive_message", message); +}; + +/** + * Notify both users in a conversation that all messages have been read. + * This triggers the double-tick (✓✓) turning blue on the sender's side. + */ +export const emitMessagesRead = ( + io: Server, + gigRequestId: string, + readByUserId: string +): void => { + const convRoom = getConversationRoom(gigRequestId); + io.to(convRoom).emit("messages_read", { gigRequestId, readByUserId }); +}; + +/** + * Notify all participants that a proposal has been accepted or rejected. + */ +export const emitProposalUpdate = ( + io: Server, + gigRequestId: string, + message: unknown +): void => { + const convRoom = getConversationRoom(gigRequestId); + io.to(convRoom).emit("receive_proposal_update", message); +}; + +/** + * Notify a specific user about an unread count update. + * Used to refresh their sidebar badge without a full page reload. + */ +export const emitUnreadUpdate = ( + io: Server, + userId: string, + gigRequestId: string, + unreadCount: number +): void => { + io.to(getUserRoom(userId)).emit("unread_update", { gigRequestId, unreadCount }); +}; diff --git a/apps/realtime/src/sockets/chat.socket.ts b/apps/realtime/src/sockets/chat.socket.ts new file mode 100644 index 0000000..81e4098 --- /dev/null +++ b/apps/realtime/src/sockets/chat.socket.ts @@ -0,0 +1,133 @@ +import { Server, Socket } from "socket.io"; + +import { getUserRoom, getConversationRoom } from "../utils/room.utils.js"; +import { + registerUserSocket, + removeUserSocket, +} from "../services/connection.service.js"; +import { emitNewMessage, emitMessagesRead, emitProposalUpdate } from "../services/emit.service.js"; + +/** + * chat.socket.ts + * + * Registers all Socket.io event handlers for the chat feature. + * This file is the "router" — it receives raw socket events and + * delegates to the proper services/utilities. + * + * Events this file handles (incoming from clients): + * - "join_conversation" → join a conversation room + * - "leave_conversation" → leave a conversation room + * - "send_message" → relay a saved message to participants + * - "mark_read" → notify sender their messages were read + * - "disconnect" → clean up user's socket registration + */ +export const registerChatHandlers = (io: Server, socket: Socket): void => { + + // ────────────────────────────────────────── + // STEP 1: Identify the connecting user + // ────────────────────────────────────────── + const userId: string | undefined = + socket.handshake.auth?.userId || + (socket.handshake.query?.userId as string | undefined); + + if (!userId) { + console.warn(`[socket] No userId provided — disconnecting ${socket.id}`); + socket.disconnect(); + return; + } + + // ────────────────────────────────────────── + // STEP 2: Join the user's personal room + // Every user has a dedicated room: "user:" + // This lets us send them messages even without them explicitly + // joining a conversation room. + // ────────────────────────────────────────── + const personalRoom = getUserRoom(userId); + socket.join(personalRoom); + + // ────────────────────────────────────────── + // STEP 3: Register in online users map + // Tracks which socket IDs belong to which user (for multi-tab support) + // ────────────────────────────────────────── + registerUserSocket(userId, socket.id); + console.log(`[socket] User ${userId} connected → room: ${personalRoom}`); + + // ────────────────────────────────────────── + // EVENT: join_conversation + // Called when user opens a specific chat window. + // Joins a shared conversation room so mark-read events propagate. + // ────────────────────────────────────────── + socket.on("join_conversation", (gigRequestId: string) => { + if (!gigRequestId) return; + const room = getConversationRoom(gigRequestId); + socket.join(room); + console.log(`[socket] User ${userId} joined conversation room: ${room}`); + }); + + // ────────────────────────────────────────── + // EVENT: leave_conversation + // Called when user navigates away from a chat window. + // ────────────────────────────────────────── + socket.on("leave_conversation", (gigRequestId: string) => { + if (!gigRequestId) return; + const room = getConversationRoom(gigRequestId); + socket.leave(room); + console.log(`[socket] User ${userId} left conversation room: ${room}`); + }); + + // ────────────────────────────────────────── + // EVENT: send_message + // Called AFTER core-api has already saved the message to DB. + // The frontend passes the saved message object (with real _id). + // We relay it to the receiver and sender's other tabs. + // ────────────────────────────────────────── + socket.on("send_message", (data: { + message: { + _id: string; + gigRequestId: string; + senderId: string; + receiverId: string; + content: string; + status: string; + createdAt: string; + } + }) => { + const { message } = data; + + if (!message || !message.receiverId || !message.senderId) { + console.warn("[socket] send_message: invalid message payload, skipping"); + return; + } + + console.log(`[socket] Relaying message ${message._id} from ${message.senderId} → ${message.receiverId}`); + emitNewMessage(io, message); + }); + + // ────────────────────────────────────────── + // EVENT: mark_read + // Called when a user opens a conversation (they've read the messages). + // Notifies the sender that their messages have been seen (double blue tick). + // ────────────────────────────────────────── + socket.on("mark_read", (gigRequestId: string) => { + if (!gigRequestId) return; + console.log(`[socket] User ${userId} marked ${gigRequestId} as read`); + emitMessagesRead(io, gigRequestId, userId); + }); + + socket.on("proposal_update", (data: { gigRequestId: string; message: unknown }) => { + const { gigRequestId, message } = data; + if (!gigRequestId || !message) return; + console.log(`[socket] Relaying proposal update for gigRequest ${gigRequestId}`); + emitProposalUpdate(io, gigRequestId, message); + }); + + // ────────────────────────────────────────── + // EVENT: disconnect + // Clean up: remove this socket from the user's tracked connections. + // If no other sockets remain, the user is marked as offline. + // ────────────────────────────────────────── + socket.on("disconnect", () => { + removeUserSocket(userId, socket.id); + console.log(`[socket] User ${userId} disconnected socket ${socket.id}`); + }); +}; \ No newline at end of file diff --git a/apps/realtime/src/utils/room.utils.ts b/apps/realtime/src/utils/room.utils.ts new file mode 100644 index 0000000..9c06b8c --- /dev/null +++ b/apps/realtime/src/utils/room.utils.ts @@ -0,0 +1,23 @@ +/** + * room.utils.ts + * + * Centralized helpers for generating consistent Socket.io room names. + * Every user gets their own personal room: "user:" + * + * Why a utility? So every part of the app uses the same format — + * if we ever change the naming convention, we change it in ONE place. + */ + +/** + * Get the personal room name for a user. + * Every socket connection joins this room on connect. + */ +export const getUserRoom = (userId: string): string => `user:${userId}`; + +/** + * Get a shared room for a specific gig request conversation. + * Both the brand and influencer join this room when they open a chat. + * Allows broadcasting to everyone in a conversation at once. + */ +export const getConversationRoom = (gigRequestId: string): string => + `conversation:${gigRequestId}`; diff --git a/apps/web/app/(admin)/adminAuth/login/page.tsx b/apps/web/app/(admin)/adminAuth/login/page.tsx new file mode 100644 index 0000000..1b81ac4 --- /dev/null +++ b/apps/web/app/(admin)/adminAuth/login/page.tsx @@ -0,0 +1,66 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; + +import api from "@/lib/axios.client"; +import { useAuthStore } from "@/store/auth.store"; +import AuthForm from "@/components/adminAuth/AuthForm"; + +export default function AdminLoginPage() { + const router = useRouter(); + const setAuth = useAuthStore((state) => state.setAuth); + + const [role, setRole] = useState<"ADMIN" | "SUPER_ADMIN">("ADMIN"); + const [formData, setFormData] = useState({ email: "", password: "" }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(""); + try { + const response = await api.post("/auth/login", formData); + const { accessToken, user } = response.data.data; + + if (accessToken) { + // 1. Role Check: Must be ADMIN + if (user.role !== "ADMIN") { + setError("Access denied: You do not have admin privileges."); + return; + } + + // 2. Admin Level Check: If SUPER_ADMIN selected, verify it + if (role === "SUPER_ADMIN" && user.adminLevel !== "SUPER") { + setError("Access denied: You do not have super admin privileges."); + return; + } + + setAuth(accessToken, user); + router.push("/admindashboard"); + } else { + setError("Login failed: No access token returned."); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + const msg = err.response?.data?.message || err.message || "Login failed. Please try again."; + setError(msg); + } finally { + setLoading(false); + } + }; + + return ( + + ); +} diff --git a/apps/web/app/(admin)/adminAuth/signup/page.tsx b/apps/web/app/(admin)/adminAuth/signup/page.tsx new file mode 100644 index 0000000..59b775c --- /dev/null +++ b/apps/web/app/(admin)/adminAuth/signup/page.tsx @@ -0,0 +1,63 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; + +import api from "@/lib/axios.client"; +import { useAuthStore } from "@/store/auth.store"; +import AuthForm from "@/components/adminAuth/AuthForm"; + +export default function AdminSignupPage() { + const router = useRouter(); + const setAuth = useAuthStore((state) => state.setAuth); + + const [role, setRole] = useState<"ADMIN" | "SUPER_ADMIN">("ADMIN"); + const [formData, setFormData] = useState({ email: "", password: "", confirmPassword: "" }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (formData.password !== formData.confirmPassword) { + setError("Passwords do not match."); + return; + } + setLoading(true); + setError(""); + try { + const payload = { + email: formData.email, + password: formData.password, + role: "ADMIN", + adminLevel: role === "SUPER_ADMIN" ? "SUPER" : "NORMAL", + documents: "" // Required by backend validator + }; + const response = await api.post("/auth/signup", payload); + const { accessToken, user } = response.data.data; + if (accessToken) { + setAuth(accessToken, user); + router.push("/admindashboard"); + } else { + setError("Signup failed: No token returned."); + } + } catch (err) { + const msg = err instanceof Error ? err.message : "Signup failed. Please try again."; + setError(msg); + } finally { + setLoading(false); + } + }; + + return ( + + ); +} diff --git a/apps/web/app/(admin)/admindashboard/activity/page.tsx b/apps/web/app/(admin)/admindashboard/activity/page.tsx new file mode 100644 index 0000000..db4a2ad --- /dev/null +++ b/apps/web/app/(admin)/admindashboard/activity/page.tsx @@ -0,0 +1,147 @@ +"use client"; +import React, { useEffect, useState, useCallback } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { Info, ChevronLeft, User } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import api from "@/lib/axios.client"; +import { FadeIn } from "@/components/animations/FadeIn"; + +interface Activity { + id: string; + type: "signup" | "gig" | "booking"; + title: string; + entityName: string; + entityImage?: string; + createdAt: string; + status: string; + icon: string; +} + +const statusColors: Record = { + signup: "bg-orange-50 text-orange-600 border-orange-100", + gig: "bg-blue-50 text-blue-600 border-blue-100", + booking: "bg-emerald-50 text-emerald-600 border-emerald-100", + default: "bg-gray-50 text-gray-600 border-gray-100", +}; + +export default function AllActivityPage() { + const [activities, setActivities] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchActivity = useCallback(async () => { + try { + const response = await api.get("/admin/recent-activity?limit=50"); + if (response.data.success) { + setActivities(response.data.data); + } + } catch (error) { + console.error("Failed to fetch all activity:", error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchActivity(); + }, [fetchActivity]); + + return ( +
+ +
+ + + Back to Overview + +
+

Platform History

+

Full audit log of recent signups, listings, and transactions.

+
+
+
+ + + {loading ? ( +
+
+

Synchronizing platform data...

+
+ ) : !activities.length ? ( +
+
+ +
+

No platform activity recorded yet.

+
+ ) : ( +
+
+ + + + + + + + + + + {activities.map((activity) => ( + + + + + + + ))} + +
Event TypePlatform EntityCurrent StatusTimestamp
+
+
+ {activity.icon} +
+
+ {activity.title} + #{activity.id.toString().slice(-6)} +
+
+
+
+ {activity.entityImage ? ( +
+ +
+ ) : ( +
+ +
+ )} + {activity.entityName} +
+
+ + {activity.status.toLowerCase()} + + + {new Date(activity.createdAt).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} +
+
+
+ )} +
+
+ ); +} diff --git a/apps/web/app/(admin)/admindashboard/bookings-audit/page.tsx b/apps/web/app/(admin)/admindashboard/bookings-audit/page.tsx new file mode 100644 index 0000000..83ac891 --- /dev/null +++ b/apps/web/app/(admin)/admindashboard/bookings-audit/page.tsx @@ -0,0 +1,176 @@ +"use client"; +import React, { useState, useEffect } from "react"; +import { + Search, + CheckCircle, + Clock, + DollarSign, + RefreshCw, + Filter, + Download +} from "lucide-react"; + +import MetricCard from "@/components/admindashboard/MetricCard"; +import MetricCardSkeleton from "@/components/admindashboard/MetricCardSkeleton"; +import TableSkeleton from "@/components/admindashboard/TableSkeleton"; +import InvestigationSkeleton from "@/components/admindashboard/InvestigationSkeleton"; +import BookingsTabs from "@/components/admindashboard/BookingsTabs"; +import BookingsTable, { Booking } from "@/components/admindashboard/BookingsTable"; +import { FadeIn } from "@/components/animations/FadeIn"; +import InvestigationView from "@/components/admindashboard/InvestigationView"; +import { AdminGuard } from "@/components/rbac/Guards"; +import api from "@/lib/axios.client"; + +interface Metrics { + completedBookings: number; + pendingPayments: number; + activeEscrows: number; + totalVolume: number; +} + +export default function BookingsAuditPage() { + const [isAtRest, setIsAtRest] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [auditData, setAuditData] = useState<{ metrics: Metrics; bookings: Booking[] } | null>(null); + const [selectedBooking, setSelectedBooking] = useState(null); + + + useEffect(() => { + const fetchAuditData = async () => { + try { + const response = await api.get("/admin/bookings"); + if (response.data.success) { + setAuditData(response.data.data); + // Automatically select the first booking if available + if (response.data.data.bookings.length > 0) { + setSelectedBooking(response.data.data.bookings[0]); + } + } + } catch (error) { + console.error("Failed to fetch bookings audit data:", error); + } finally { + setIsLoading(false); + } + }; + + fetchAuditData(); + }, []); + + const metrics = auditData?.metrics || { + completedBookings: 0, + pendingPayments: 0, + activeEscrows: 0, + totalVolume: 0 + }; + + return ( + +
+ {/* Left Column: Metrics & Table */} +
+ {/* Welcome Header */} +
+
+

Bookings & Payments Audit

+

Investigate transaction history and booking lifecycles. Read-only access.

+
+ +
+ + {/* Metric Cards */} + setIsAtRest(true)} delay={0.1}> +
+ {isLoading ? ( + <> + + + + + + ) : ( + <> + + + + + + )} +
+
+ + {/* Moderation Controls */} +
+ +
+
+ + +
+ +
+
+ + {/* Bookings Table */} + + {isLoading ? ( + + ) : ( + + )} + +
+ + {/* Right Column: Investigation View */} +
+ + {isLoading ? ( + + ) : ( + + )} + +
+
+
+ ); +} + diff --git a/apps/web/app/(admin)/admindashboard/disputes-reports/page.tsx b/apps/web/app/(admin)/admindashboard/disputes-reports/page.tsx new file mode 100644 index 0000000..3201d71 --- /dev/null +++ b/apps/web/app/(admin)/admindashboard/disputes-reports/page.tsx @@ -0,0 +1,141 @@ +"use client"; +import React, { useState, useEffect, useCallback } from "react"; +import { + Search, + Clock, + CheckCircle2, + Users, + Filter +} from "lucide-react"; + +import MetricCard from "@/components/admindashboard/MetricCard"; +import MetricCardSkeleton from "@/components/admindashboard/MetricCardSkeleton"; +import TableSkeleton from "@/components/admindashboard/TableSkeleton"; +import InvestigationSkeleton from "@/components/admindashboard/InvestigationSkeleton"; +import DisputeTabs from "@/components/admindashboard/DisputeTabs"; +import DisputeTable, { Report } from "@/components/admindashboard/DisputeTable"; +import DisputeInvestigation from "@/components/admindashboard/DisputeInvestigation"; +import { FadeIn } from "@/components/animations/FadeIn"; +import { AdminGuard } from "@/components/rbac/Guards"; +import api from "@/lib/axios.client"; + +export default function DisputesReportsPage() { + const [isAtRest, setIsAtRest] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [reports, setReports] = useState([]); + const [selectedReportId, setSelectedReportId] = useState(null); + + + const fetchReports = useCallback(async () => { + try { + const res = await api.get("/admin/reports"); + setReports(res.data.data); + if (res.data.data.length > 0 && !selectedReportId) { + setSelectedReportId(res.data.data[0]._id); + } + } catch (err) { + console.error(err); + } finally { + setIsLoading(false); + } + }, [selectedReportId]); + + useEffect(() => { + fetchReports(); + const timer = setTimeout(() => setIsAtRest(true), 500); + return () => clearTimeout(timer); + }, [fetchReports]); + + return ( + +
+ {/* Left Column: Metrics & Table */} +
+ {/* Welcome Header */} +
+

Disputes & Reports

+

Resolve conflicts safely and transparently.

+
+ + {/* Metrics Grid */} + setIsAtRest(true)} delay={0.1}> +
+ {isLoading ? ( + <> + + + + + ) : ( + <> + + + + + )} +
+
+ + {/* Search & Filter Controls */} +
+ +
+
+ + +
+ +
+
+ + {/* Disputes Table */} + + {isLoading ? ( + + ) : ( + + )} + +
+ + {/* Right Column: Investigation View */} +
+ + {isLoading ? ( + + ) : ( + + )} + +
+
+
+ ); +} diff --git a/apps/web/app/(admin)/admindashboard/gig-moderation/page.tsx b/apps/web/app/(admin)/admindashboard/gig-moderation/page.tsx new file mode 100644 index 0000000..f487688 --- /dev/null +++ b/apps/web/app/(admin)/admindashboard/gig-moderation/page.tsx @@ -0,0 +1,174 @@ +"use client"; +import React, { useState, useCallback, useMemo } from "react"; +import { + PauseCircle, + Filter, + Search, + CheckCircle, + Flag, + TrendingUp, + ChevronDown +} from "lucide-react"; + +import MetricCard from "@/components/admindashboard/MetricCard"; +import MetricCardSkeleton from "@/components/admindashboard/MetricCardSkeleton"; +import GigsTabs from "@/components/admindashboard/GigsTabs"; +import GigsTable, { type Gig } from "@/components/admindashboard/GigsTable"; +import DeleteConfirmationModal from "@/components/admindashboard/DeleteConfirmationModal"; +import { FadeIn } from "@/components/animations/FadeIn"; +import api from "@/lib/axios.client"; +import { useGigStats } from "@/hooks/useGigStats"; + +export default function GigsModerationPage() { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedGig, setSelectedGig] = useState(null); + const [isAtRest, setIsAtRest] = useState(false); + + const { stats, loading: statsLoading } = useGigStats(); + + const [activeTab, setActiveTab] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); + const [allGigs, setAllGigs] = useState([]); + + // Computed counts for GigsTabs based on allGigs currently held in state + const tabCounts = useMemo(() => ({ + all: allGigs.length, + reported: allGigs.filter((g) => g.status === "reported").length, + paused: allGigs.filter((g) => g.status === "paused").length, + }), [allGigs]); + + + const handleDeleteClick = (gig: Gig) => { + setSelectedGig(gig); + setIsDeleteModalOpen(true); + }; + + const handleConfirmDelete = useCallback(async () => { + if (!selectedGig) return; + try { + await api.delete(`/gigs/${selectedGig._id}`); + setAllGigs((prev) => prev.filter((g) => g._id !== selectedGig._id)); + } catch (error) { + console.error("Failed to delete gig:", error); + } finally { + setIsDeleteModalOpen(false); + setSelectedGig(null); + } + }, [selectedGig]); + + return ( +
+ {/* Page Header */} + +
+
+

Gig Moderation

+

Review and manage influencer service listings to ensure platform compliance.

+
+ +
+
+ + {/* Metrics Grid */} + setIsAtRest(true)} delay={0.1}> +
+ {statsLoading ? ( + <> + + + + + + ) : ( + <> + + 0 ? "Action Required" : "All Clear"} + icon={Flag} + iconColor="text-red-500" + iconBg="bg-red-50" + /> + + + + )} +
+
+ + {/* Moderation Controls & Table Section */} +
+
+ + +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search gigs, influencers..." + className="w-full pl-11 pr-4 py-3 text-sm font-bold text-[#111827] bg-gray-50/50 border border-transparent rounded-2xl focus:bg-white focus:border-gray-100 focus:ring-4 focus:ring-gray-100/50 outline-none transition-all placeholder:text-gray-400" + /> +
+ + + + + + +
+
+ + + + +
+ + {/* Delete Confirmation Modal */} + setIsDeleteModalOpen(false)} + onConfirm={handleConfirmDelete} + /> +
+ ); +} diff --git a/apps/web/app/(admin)/admindashboard/layout.tsx b/apps/web/app/(admin)/admindashboard/layout.tsx new file mode 100644 index 0000000..3fcd6bf --- /dev/null +++ b/apps/web/app/(admin)/admindashboard/layout.tsx @@ -0,0 +1,80 @@ +"use client"; + +import React, { useState } from "react"; +import { Menu, Search, Bell, Download, Plus } from "lucide-react"; + +import { AdminGuard } from "@/components/rbac/Guards"; +import Sidebar from "@/components/admindashboard/Sidebar"; +import { FadeIn } from "@/components/animations/FadeIn"; + +export default function AdminDashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + return ( + +
+ {/* Sidebar */} + + + {/* Main Content */} +
+ {/* Topbar */} +
+ +
+ +
+ + +
+
+
+ +
+ + + + +
+ + + + + + + + +
+
+ + {/* Page Content */} +
+ {children} +
+
+
+
+ ); +} diff --git a/apps/web/app/(admin)/admindashboard/loading.tsx b/apps/web/app/(admin)/admindashboard/loading.tsx new file mode 100644 index 0000000..3467f65 --- /dev/null +++ b/apps/web/app/(admin)/admindashboard/loading.tsx @@ -0,0 +1,60 @@ +"use client"; +import React, { useState } from "react"; + +import { FadeIn } from "@/components/animations/FadeIn"; +import MetricCardSkeleton from "@/components/admindashboard/MetricCardSkeleton"; +import TableSkeleton from "@/components/admindashboard/TableSkeleton"; +import StatsChartSkeleton from "@/components/admindashboard/StatsChartSkeleton"; +import SystemHealthSkeleton from "@/components/admindashboard/SystemHealthSkeleton"; + +export default function AdminDashboardLoading() { + const [isAtRest, setIsAtRest] = useState(false); + + return ( + <> + {/* Header Placeholder */} +
+ +
+
+
+
+
+ +
+
+
+ + {/* Skeletons mimicking the Dashboard Metrics Grid */} + setIsAtRest(true)} delay={0.2}> +
+ + + + +
+
+ + {/* Main Grid Skeletons */} +
+
+ + + +
+
+
+ + + +
+
+ + + +
+
+
+ + ); +} diff --git a/apps/web/app/(admin)/admindashboard/page.tsx b/apps/web/app/(admin)/admindashboard/page.tsx new file mode 100644 index 0000000..e5d4d0d --- /dev/null +++ b/apps/web/app/(admin)/admindashboard/page.tsx @@ -0,0 +1,114 @@ +"use client"; +import React, { useState } from "react"; +import { + Users, + UserCheck, + Briefcase, + DollarSign +} from "lucide-react"; + +import MetricCard from "@/components/admindashboard/MetricCard"; +import MetricCardSkeleton from "@/components/admindashboard/MetricCardSkeleton"; +import ActivityTable from "@/components/admindashboard/ActivityTable"; +import StatsChart from "@/components/admindashboard/StatsChart"; +import SystemHealth from "@/components/admindashboard/SystemHealth"; +import TableSkeleton from "@/components/admindashboard/TableSkeleton"; +import StatsChartSkeleton from "@/components/admindashboard/StatsChartSkeleton"; +import SystemHealthSkeleton from "@/components/admindashboard/SystemHealthSkeleton"; +import { useAdminStats } from "@/hooks/useAdminStats"; +import { FadeIn } from "@/components/animations/FadeIn"; +import { Button } from "@/components/ui/button"; + +export default function DashboardPage() { + const { requests, userCount, gigCount, revenue, loading } = useAdminStats(); + const [isAtRest, setIsAtRest] = useState(false) + return ( + <> + {/* Welcome Header */} +
+ +
+

Platform Overview

+

Welcome back, here's what's happening today.

+
+
+ + + +
+ + {/* Metrics Grid */} + + setIsAtRest(true)} delay={0.2}> +
+ {loading ? ( + <> + + + + + + ) : ( + <> + + + + + + )} +
+
+ + {/* Main Grid: Activity and Trends */} +
+
+ + {loading ? : } + +
+
+
+ + {loading ? : } + +
+
+ + {loading ? : } + +
+
+
+ + ); +} diff --git a/apps/web/app/(admin)/admindashboard/user-verification/page.tsx b/apps/web/app/(admin)/admindashboard/user-verification/page.tsx new file mode 100644 index 0000000..46fa557 --- /dev/null +++ b/apps/web/app/(admin)/admindashboard/user-verification/page.tsx @@ -0,0 +1,173 @@ +"use client"; +import React, { useState } from "react"; +import { + Users, + DollarSign, + Search, + Clock, + Zap +} from "lucide-react"; + +import MetricCard from "@/components/admindashboard/MetricCard"; +import MetricCardSkeleton from "@/components/admindashboard/MetricCardSkeleton"; +import VerificationTable from "@/components/admindashboard/VerificationTable"; +import TableSkeleton from "@/components/admindashboard/TableSkeleton"; +import VerificationTabs from "@/components/admindashboard/VerificationTabs"; +import UserDetailsDrawer from "@/components/admindashboard/UserDetailsDrawer"; +import api from "@/lib/axios.client"; +import { useAdminStats } from "@/hooks/useAdminStats"; +import { FadeIn } from "@/components/animations/FadeIn"; + +export default function VerificationPage() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [selectedUser, setSelectedUser] = useState(null); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [isAtRest, setIsAtRest] = useState(false); + const [activeTab, setActiveTab] = useState("all"); + + const { requests, userCount, gigCount, loading, refresh } = useAdminStats(); + + // Filter requests based on active tab + const filteredRequests = requests.filter((r) => { + if (activeTab === "all") return true; + if (activeTab === "influencers") return r.role === "INFLUENCER"; + if (activeTab === "brands") return r.role === "BRAND"; + return true; + }); + + const tabCounts = { + all: requests.length, + influencers: requests.filter((r) => r.role === "INFLUENCER").length, + brands: requests.filter((r) => r.role === "BRAND").length, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleSelectUser = (user: any) => { + setSelectedUser(user); + setIsDrawerOpen(true); + }; + + const handleApprove = async (email: string) => { + try { + const response = await api.post("/admin/signup/approve", { email }); + if (response.data.success) { + // Refresh list + refresh(); + } + } catch (error) { + console.error("Failed to approve:", error); + } + }; + + const handleReject = async (email: string) => { + try { + const response = await api.post("/admin/signup/reject", { email }); + if (response.data.success) { + // Refresh list + refresh(); + } + } catch (error) { + console.error("Failed to reject:", error); + } + }; + + return ( + <> + {/* Welcome Header */} +
+

User Verification

+

Review and manage pending influencer and brand applications.

+
+ + {/* Metric Cards */} + setIsAtRest(true)} delay={0.1}> +
+ {loading ? ( + <> + + + + + + ) : ( + <> + + + + + + )} +
+
+ + {/* Verification Controls */} +
+ +
+
+ + +
+
+
+ + {/* Verification Table */} + + {loading ? ( + + ) : ( + + )} + + + {/* User Details Drawer */} + setIsDrawerOpen(false)} + user={selectedUser} + onApprove={handleApprove} + onReject={handleReject} + /> + + ); +} diff --git a/apps/web/app/(auth)/forget-Password/page.tsx b/apps/web/app/(auth)/forget-Password/page.tsx new file mode 100644 index 0000000..08ef528 --- /dev/null +++ b/apps/web/app/(auth)/forget-Password/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { motion } from "framer-motion"; + +import api from "@/lib/axios.client"; +import SetupNavbar from "@/components/SetupNavbar"; + +export default function ForgotPassword() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(""); + + try { + await api.post("/auth/forgot-password", { email }); + router.push(`/verify-otp?email=${encodeURIComponent(email)}&type=reset`); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to send OTP. Please try again."; + const errorObj = err as { response?: { data?: { message?: string } } }; + if (errorObj.response?.data?.message) { + setError(errorObj.response.data.message); + } else { + setError(errorMessage); + } + } finally { + setLoading(false); + } + }; + + return ( +
+ + +
+ +
+

+ Forgot Password? +

+

+ No worries, we'll send you reset instructions via email. +

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setEmail(e.target.value)} + required + className="w-full border border-gray-200 rounded-xl px-4 py-3.5 text-gray-800 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-50 focus:border-emerald-500 transition-all bg-gray-50/50" + /> +
+ + + {loading ? "Sending OTP..." : "Send Reset Code"} + +
+ +
+

+ Remembered your password?{" "} + + Sign In + +

+
+
+
+
+ ); +} diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx new file mode 100644 index 0000000..2a57e36 --- /dev/null +++ b/apps/web/app/(auth)/login/page.tsx @@ -0,0 +1,222 @@ +"use client"; + +import Link from "next/link"; +import React, { useState, useEffect, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { motion, AnimatePresence } from "framer-motion"; +import { Eye, EyeOff } from "lucide-react"; + +import api from "@/lib/axios.client"; +import { useAuthStore } from "@/store/auth.store"; +import AuthNavbar from "@/components/AuthNavbar"; + +function LoginForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [role, setRole] = useState<"BRAND" | "INFLUENCER">("BRAND"); + const [formData, setFormData] = useState({ + email: "", + password: "" + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const setAuth = useAuthStore((state) => state.setAuth) + + useEffect(() => { + const urlRole = searchParams.get("role")?.toUpperCase(); + if (urlRole === "INFLUENCER" || urlRole === "BRAND") { + setRole(urlRole as "BRAND" | "INFLUENCER"); + } + }, [searchParams]); + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + + })); + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(""); + try { + const payload = { + email: formData.email, + password: formData.password, + role: role + } + + const response = await api.post("/auth/login", payload); + + // Access token is now in response.data.data.accessToken + if (response.data.data?.accessToken) { + const { accessToken, user } = response.data.data + setAuth(accessToken, user) + const dashboardPath = user.role === "BRAND" ? "/brand-dashboard" : "/influencer-dashboard"; + router.push(dashboardPath); + } + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Login failed. Please try again."; + setError(errorMessage); + } finally { + setLoading(false); + } + } + + + return ( +
+ + +
+ + + +
+

+ Welcome Back +

+

+ Log in to your Noillin account as{" "} + {role.toLowerCase()} +

+
+ +
+ + + + +
+ +
+ {error && ( +
{error}
+ )} +
+ + +
+ +
+
+ +

+ + forgot password + +

+
+ +
+ + +
+
+ + + {loading ? "Signing In..." : `Sign In as ${role === "BRAND" ? "Brand" : "Influencer"}`} + +
+ +

+ Don't have an account?{" "} + + Create new account + +

+ +

+ Secure login · Data protected +

+
+
+
+ ); +} + +export default function LoginPage() { + return ( + Loading...}> + + + ); +} diff --git a/apps/web/app/(auth)/register/page.tsx b/apps/web/app/(auth)/register/page.tsx new file mode 100644 index 0000000..5b98fa3 --- /dev/null +++ b/apps/web/app/(auth)/register/page.tsx @@ -0,0 +1,135 @@ + +"use client"; + +import { useState } from "react"; + +export default function LoginPage() { + const [role, setRole] = useState<"brand" | "influencer">("brand"); + + return ( +
+
+ +
+

+ Welcome Back +

+

+ Join as a Brand or Influencer + +

+
+ +
+ + + +
+ +
+ +
+ + +
+
+ + +
+ +
+
+ + +
+ + +
+
+ + +
+
+ {role === "brand" ? ( + <> + + + + ) : ( + <> + + + + )} +
+ + + +
+ +

+ Secure login · Data protected +

+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/app/(auth)/reset-password/page.tsx b/apps/web/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..346262f --- /dev/null +++ b/apps/web/app/(auth)/reset-password/page.tsx @@ -0,0 +1,211 @@ +"use client"; + +import React, { useState, useEffect, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { motion, AnimatePresence } from "framer-motion"; +import { Eye, EyeOff, CheckCircle2 } from "lucide-react"; + +import api from "@/lib/axios.client"; +import SetupNavbar from "@/components/SetupNavbar"; + +function ResetPasswordContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const email = searchParams.get("email") || ""; + const resetSessionToken = searchParams.get("token") || ""; + + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + useEffect(() => { + if (!email || !resetSessionToken) { + router.push("/forget-Password"); + } + }, [email, resetSessionToken, router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (newPassword !== confirmPassword) { + setError("Passwords do not match"); + return; + } + setLoading(true); + setError(""); + + try { + await api.post("/auth/reset-password", { + email, + newPassword, + resetSessionToken + }); + setSuccess(true); + setTimeout(() => { + router.push("/login"); + }, 2000); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to reset password."; + const errorObj = err as { response?: { data?: { message?: string } } }; + if (errorObj.response?.data?.message) { + setError(errorObj.response.data.message); + } else { + setError(errorMessage); + } + } finally { + setLoading(false); + } + }; + + if (success) { + return ( +
+ +
+ +
+
+ +
+
+

Reset Successful!

+

+ Your password has been securely updated. You can now log in with your new credentials. +

+
+
+ Taking you to login... +
+
+
+
+ ); + } + + return ( +
+ + +
+ +
+

+ Secure Your Account +

+

+ Create a strong password that you haven't used before. +

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+
+ +
+ setNewPassword(e.target.value)} + required + className="w-full border border-gray-200 rounded-xl px-4 py-3.5 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-50 focus:border-emerald-500 transition-all bg-gray-50/50 pr-12" + /> + +
+
+ +
+ +
+ setConfirmPassword(e.target.value)} + required + className="w-full border border-gray-200 rounded-xl px-4 py-3.5 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-50 focus:border-emerald-500 transition-all bg-gray-50/50 pr-12" + /> + +
+
+
+ + + {loading ? "Updating Password..." : "Update Password"} + +
+
+
+
+ ); +} + +export default function ResetPassword() { + return ( + Loading...}> + + + ); +} \ No newline at end of file diff --git a/apps/web/app/(auth)/verify-otp/page.tsx b/apps/web/app/(auth)/verify-otp/page.tsx new file mode 100644 index 0000000..9ba5a44 --- /dev/null +++ b/apps/web/app/(auth)/verify-otp/page.tsx @@ -0,0 +1,170 @@ +"use client"; + +import React, { useState, useEffect, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { motion } from "framer-motion"; + + +import api from "@/lib/axios.client"; +import SetupNavbar from "@/components/SetupNavbar"; + +function VerifyOtpContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const email = searchParams.get("email") || ""; + const verificationType = searchParams.get("type") || "reset"; + + const [otp, setOtp] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [resendLoading, setResendLoading] = useState(false); + const [resendMessage, setResendMessage] = useState(""); + const [countdown, setCountdown] = useState(0); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (countdown > 0) { + timer = setInterval(() => { + setCountdown((prev) => prev - 1); + }, 1000); + } + return () => clearInterval(timer); + }, [countdown]); + + // Redirect back if no email is provided (only for reset flow) + useEffect(() => { + if (!email && verificationType === "reset") { + router.push("/forget-Password"); + } + }, [email, router, verificationType]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(""); + + try { + if (verificationType === "signup") { + await api.post("/auth/verify-signup-otp", { email, otp }); + // ✅ OTP verified — go directly to profile setup + router.push("/profile-setup"); + } else { + const response = await api.post("/auth/verify-reset-otp", { email, otp }); + const resetSessionToken = response.data?.data?.resetSessionToken; + + if (resetSessionToken) { + router.push(`/reset-password?email=${encodeURIComponent(email)}&token=${encodeURIComponent(resetSessionToken)}`); + } else { + setError("Invalid response from server. Missing token."); + } + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to verify OTP."; + const errorObj = err as { response?: { data?: { message?: string } } }; + if (errorObj.response?.data?.message) { + setError(errorObj.response.data.message); + } else { + setError(errorMessage); + } + } finally { + setLoading(false); + } + }; + + const handleResend = async () => { + if (!email) return; + setResendLoading(true); + setError(""); + setResendMessage(""); + try { + if (verificationType === "signup") { + await api.post("/auth/resend-signup-otp", { email }); + } else { + await api.post("/auth/forgot-password", { email }); + } + setResendMessage("OTP resent successfully!"); + setCountdown(60); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to resend OTP."; + const errorObj = err as { response?: { data?: { message?: string } } }; + if (errorObj.response?.data?.message) { + setError(errorObj.response.data.message); + } else { + setError(errorMessage); + } + } finally { + setResendLoading(false); + } + }; + + return ( +
+ {(verificationType === "signup" || verificationType === "reset") && ( + + )} + +
+ + +

Verify OTP

+ +

+ Enter the 6-digit OTP sent to {email ? {email} : "your email"} +

+ +
+ {error &&
{error}
} + {resendMessage &&
{resendMessage}
} + + setOtp(e.target.value.replace(/\D/g, ''))} + required + placeholder="Enter OTP" + className="w-full px-4 text-center tracking-[1em] font-black text-2xl border border-gray-200 rounded-xl py-4 focus:outline-none focus:ring-2 focus:ring-emerald-50 focus:border-emerald-500 transition-all bg-gray-50/50" + /> + + + {loading ? "Verifying..." : "Verify OTP"} + + + +

+ Didn't receive OTP?{" "} + +

+ +
+
+
+ ); +} + +export default function VerifyOtp() { + return ( + Loading...}> + + + ); +} \ No newline at end of file diff --git a/apps/web/app/(payments)/cancel/page.tsx b/apps/web/app/(payments)/cancel/page.tsx new file mode 100644 index 0000000..a88a4b8 --- /dev/null +++ b/apps/web/app/(payments)/cancel/page.tsx @@ -0,0 +1,3 @@ +export default function CancelPage() { + return

Payment Cancelled ❌

; +} \ No newline at end of file diff --git a/apps/web/app/(payments)/payment/cancel/page.tsx b/apps/web/app/(payments)/payment/cancel/page.tsx new file mode 100644 index 0000000..d912ecf --- /dev/null +++ b/apps/web/app/(payments)/payment/cancel/page.tsx @@ -0,0 +1,51 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { XCircle, ArrowLeft, MessageCircle, ShieldAlert } from "lucide-react"; + +export default function PaymentCancelPage() { + return ( +
+
+ + {/* ❌ Cancel Icon */} +
+
+ +
+
+ +

Payment Cancelled

+

+ No worries! You haven't been charged. You can return to the chat to continue your negotiation or try again later. +

+ + {/* 🔘 Action Buttons */} +
+ + + Return to Chat + + + +
+ + {/* 🛡️ Secure Note */} +
+ + Your payment info is never stored +
+
+
+ ); +} diff --git a/apps/web/app/(payments)/payment/page.tsx b/apps/web/app/(payments)/payment/page.tsx new file mode 100644 index 0000000..0aae1ab --- /dev/null +++ b/apps/web/app/(payments)/payment/page.tsx @@ -0,0 +1,95 @@ +"use client"; + +import React, { useEffect, useState, Suspense } from "react"; +import { useSearchParams } from "next/navigation"; +import { ShieldCheck, CreditCard, Loader2 } from "lucide-react"; + +import api from "@/lib/axios.client"; + +function PaymentContent() { + const searchParams = useSearchParams(); + const orderId = searchParams.get("orderId"); + const [error, setError] = useState(null); + + useEffect(() => { + const startCheckout = async () => { + if (!orderId) { + setError("Order ID is missing."); + return; + } + + try { + // 🔥 Directly create Stripe Session (Backend now fetches amount automatically) + const res = await api.post("/payments/checkout", { + orderId + }); + + if (res.data.url) { + window.location.href = res.data.url; + } else { + throw new Error("Failed to get checkout URL from server."); + } + } catch (err: unknown) { + console.error(err); + const errorResponse = err as { response?: { data?: { message?: string } } }; + setError(errorResponse.response?.data?.message || "Something went wrong while starting checkout."); + } + }; + + startCheckout(); + }, [orderId]); + + if (error) { + return ( +
+
+
+ +
+

Checkout Failed

+

{error}

+ +
+
+ ); + } + + return ( +
+
+
+
+ +
+
+ +

Preparing Secure Checkout

+

+ Setting up your encrypted payment session with Stripe. You'll be redirected in a moment. +

+ +
+ + PCI DSS Compliant · Secure Escrow +
+
+ ); +} + +export default function Page() { + return ( + + +

Initializing secure checkout...

+ + }> + +
+ ); +} \ No newline at end of file diff --git a/apps/web/app/(payments)/payment/success/page.tsx b/apps/web/app/(payments)/payment/success/page.tsx new file mode 100644 index 0000000..a934041 --- /dev/null +++ b/apps/web/app/(payments)/payment/success/page.tsx @@ -0,0 +1,153 @@ +"use client"; + +import React, { useEffect, useState, Suspense } from "react"; +import Link from "next/link"; +import { useSearchParams, useRouter } from "next/navigation"; +import { Check, ShieldCheck, Printer, History } from "lucide-react"; + +import { useAuthStore } from "@/store/auth.store"; +import api from "@/lib/axios.client"; + +interface Order { + _id: string; + amount: number; + stripePaymentIntentId?: string; + connectionId?: string; + [key: string]: unknown; // Allow other fields with unknown type to satisfy lint +} + +function PaymentSuccessContent() { + const searchParams = useSearchParams(); + const orderId = searchParams.get("orderId"); + const [order, setOrder] = useState(null); + const [loading, setLoading] = useState(true); + const [countdown, setCountdown] = useState(5); + + const router = useRouter(); + const { user } = useAuthStore(); + const chatDashboardPath = user?.role?.toLowerCase() === 'influencer' ? 'influencer-dashboard' : 'brand-dashboard'; + + useEffect(() => { + const fetchOrder = async () => { + try { + if (searchParams.get("session_id")) { + const res = await api.get(`/orders/by-session/${searchParams.get("session_id")}`); + setOrder(res.data); + } else if (orderId) { + const res = await api.get(`/orders/details/${orderId}`); + setOrder(res.data); + } + } catch (err) { + console.error("Error fetching order:", err); + } finally { + setLoading(false); + } + }; + fetchOrder(); + }, [orderId, searchParams]); + + useEffect(() => { + if (!order?.connectionId) return; + + const timer = setInterval(() => { + setCountdown((prev) => prev <= 1 ? 0 : prev - 1); + }, 1000); + + return () => clearInterval(timer); + }, [order?.connectionId]); + + useEffect(() => { + if (countdown === 0 && order?.connectionId) { + router.push(`/${chatDashboardPath}/messages?gigRequestId=${order.connectionId}`); + } + }, [countdown, order?.connectionId, router, chatDashboardPath]); + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ + {/* 🧾 The Receipt Modal */} +
+ +
+ {/* ✅ Success Icon */} +
+
+ +
+
+ +

Payment Successful

+ + {/* 📋 Data Table */} +
+
+ Payment Account + Standard Account +
+
+ Transaction ID + {order?._id || "---"} +
+
+ Stripe Reference + {order?.stripePaymentIntentId || "Direct Payment"} +
+ +
+
+ Amount Paid + ₹{order?.amount?.toLocaleString() || "0.00"} +
+

Including all taxes

+
+
+ + {/* 🔘 Action Buttons */} +
+
+ + + + History + +
+ + Go Back to Chat ({countdown}s) + +
+
+ + {/* 🛡️ Trust Footer */} +
+ + Verified Secure Escrow Transaction +
+
+ +
+ ); +} + +export default function PaymentSuccessPage() { + return ( + +
+ + }> + +
+ ); +} + diff --git a/apps/web/app/brand-dashboard/bookings/page.tsx b/apps/web/app/brand-dashboard/bookings/page.tsx new file mode 100644 index 0000000..7015f36 --- /dev/null +++ b/apps/web/app/brand-dashboard/bookings/page.tsx @@ -0,0 +1,468 @@ +"use client"; + +import React, { useState, useEffect, Suspense } from "react"; +import { Search, ChevronRight, CheckCircle2, Loader2, ShieldCheck, Download, Undo2, Ban } from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import Image from "next/image"; + +import api from "@/lib/axios.client"; +import { SecureMediaPreview } from "@/components/shared/SecureMediaPreview"; + +export default function BrandBookingsPage() { + return ( + }> + + + ); +} + +function BrandBookingsContent() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [bookingsData, setBookingsData] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedId, setSelectedId] = useState(null); + const [activeFilter, setActiveFilter] = useState("All"); + const [searchQuery, setSearchQuery] = useState(""); + const searchParams = useSearchParams(); + const orderIdParam = searchParams.get("orderId"); + + const [approving, setApproving] = useState(false); + const [rejecting, setRejecting] = useState(false); + const [rejectionNote, setRejectionNote] = useState(""); + const [showRejectForm, setShowRejectForm] = useState(false); + const getStatusLabel = (status: string) => { + switch (status) { + case "COMPLETED": return "Completed"; + case "IN_ESCROW": return "Securely Booked"; + case "PENDING": return "Payment Pending"; + case "DISPUTED": return "Disputed"; + case "CANCELLED": return "Cancelled"; + case "REJECTED": return "Revision Requested"; + default: return status; + } + }; + + const getStatusStyles = (status: string) => { + switch (status) { + case "COMPLETED": return "bg-emerald-50 text-emerald-600"; + case "IN_ESCROW": return "bg-blue-50 text-blue-600"; + case "PENDING": return "bg-orange-50 text-orange-600 font-bold"; + case "DISPUTED": return "bg-rose-50 text-rose-600"; + default: return "bg-gray-100 text-gray-800"; + } + }; + + const handleApproveWork = async (orderId: string) => { + try { + setApproving(true); + await api.patch(`/orders/approve/${orderId}`); + setBookingsData(prev => prev.map(b => b._id === orderId ? { ...b, status: "COMPLETED", workStatus: "APPROVED", escrowStatus: "RELEASED" } : b)); + } catch (err) { + console.error("Failed to approve work:", err); + } finally { + setApproving(false); + } + }; + + const handleRejectWork = async (orderId: string) => { + if (!rejectionNote) return; + try { + setRejecting(true); + await api.patch(`/orders/reject/${orderId}`, { note: rejectionNote }); + setBookingsData(prev => prev.map(b => b._id === orderId ? { ...b, workStatus: "REJECTED", rejectionNote } : b)); + setShowRejectForm(false); + setRejectionNote(""); + } catch (err) { + console.error("Failed to reject work:", err); + } finally { + setRejecting(false); + } + }; + + useEffect(() => { + api.get("/orders/history").then((res) => { + setBookingsData(res.data); + if (orderIdParam) { + setSelectedId(orderIdParam); + } else if (res.data.length > 0) { + setSelectedId(res.data[0]._id); + } + }).finally(() => setLoading(false)); + }, [orderIdParam]); + + const filteredBookings = bookingsData.filter(b => { + const titleMatch = (b.gigId?.title || "").toLowerCase().includes(searchQuery.toLowerCase()); + let statusMatch = true; + + if (activeFilter === "Active") { + statusMatch = b.status === "IN_ESCROW" || b.status === "PENDING"; + } else if (activeFilter === "Completed") { + statusMatch = b.status === "COMPLETED"; + } else if (activeFilter === "Disputed") { + statusMatch = b.status === "DISPUTED"; + } + + return titleMatch && statusMatch; + }); + + const selectedBooking = bookingsData.find(b => b._id === selectedId) || null; + + if (loading) { + return ( +
+ +
+ ); + } + + const generateTimeline = (status: string, workStatus: string) => { + const steps = [ + { label: "Request Placed & Accepted", status: 'completed' }, + { label: "Payment Secured in Escrow", status: (status === 'IN_ESCROW' || status === 'COMPLETED') ? 'completed' : 'current' }, + { label: "Work Submission & Review", status: (workStatus === 'SUBMITTED' || workStatus === 'APPROVED' || status === 'COMPLETED') ? 'completed' : (status === 'IN_ESCROW') ? 'current' : 'pending' }, + { label: "Project Finalized", status: (status === 'COMPLETED') ? 'completed' : (workStatus === 'APPROVED') ? 'current' : 'pending' }, + ]; + + // Handle Rejection Case + if (workStatus === 'REJECTED') { + steps[2] = { label: "Revision Requested", status: 'current' }; + } + + return steps; + }; + + return ( +
+ {/* Header Area */} +
+
+

Bookings

+

Track and manage your ongoing collaborations

+
+ +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + /> +
+ + {/* Filters */} +
+ {["All", "Active", "Completed", "Disputed"].map((filter) => ( + + ))} +
+
+
+ +
+ {/* Left Column: Table */} +
+
+ + + + + + + + + + + {filteredBookings.length === 0 && ( + + + + )} + {filteredBookings.map((booking) => ( + setSelectedId(booking._id)} + className={`transition-all cursor-pointer group ${selectedId === booking._id ? "bg-emerald-50/30" : "hover:bg-gray-50/50" + }`} + > + + + + + + ))} + +
InfluencerGig NamePrice
No bookings found
+
+
+ {booking.influencerProfile?.profileImageUrl ? ( + { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + ) : ( +
+ {(booking.influencerProfile?.fullName || booking.influencerProfile?.username || "A").charAt(0).toUpperCase()} +
+ )} +
+ {booking.influencerProfile?.fullName || booking.influencerProfile?.username || "Unknown Influencer"} +
+
+ {booking.gigId?.title} + + + {getStatusLabel(booking.status)} + + + +
+
+
+ + {/* Right Column: Detail View */} +
+
+ {!selectedBooking ? ( +
+ Select a booking from the list +
+ ) : ( + <> +
+
+
+ {selectedBooking.influencerProfile?.profileImageUrl ? ( + { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + ) : ( +
+ {(selectedBooking.influencerProfile?.fullName || selectedBooking.influencerProfile?.username || "A").charAt(0).toUpperCase()} +
+ )} +
+
+

{selectedBooking.influencerProfile?.fullName || selectedBooking.influencerProfile?.username || "Unknown Influencer"}

+ + {getStatusLabel(selectedBooking.status)} + +
+ +
+
+ Gig Name + {selectedBooking.gigId?.title} +
+
+ Order Status + {getStatusLabel(selectedBooking.status)} +
+
+ Booked Date + {new Date(selectedBooking.createdAt).toLocaleDateString()} +
+ + {selectedBooking.dueDate && ( +
+ Due Date +
+
{new Date(selectedBooking.dueDate).toLocaleDateString()}
+
+
+ )} + +
+ Total Price + ₹{(selectedBooking.amount || 0).toLocaleString()} +
+ + {/* Timeline */} +
+

Timeline

+
+ {generateTimeline(selectedBooking.status, selectedBooking.workStatus).map((step, idx) => ( +
+
+
+ {step.status === 'completed' ? ( + + ) : ( +
+ )} +
+ {idx !== 3 && ( +
+ )} +
+
+

+ {step.label} +

+ {step.status === 'current' && ( +

Current Step

+ )} +
+
+ ))} +
+
+
+ +
+ {selectedBooking.status === "IN_ESCROW" && selectedBooking.workStatus === "SUBMITTED" ? ( +
+
+

Secure Deliverable Preview

+ +
+ + {showRejectForm ? ( +
+
+