diff --git a/server/src/middleware/usage-limit.middleware.ts b/server/src/middleware/usage-limit.middleware.ts index 55e22e2f5..94d3ae90e 100644 --- a/server/src/middleware/usage-limit.middleware.ts +++ b/server/src/middleware/usage-limit.middleware.ts @@ -14,41 +14,63 @@ export function usageLimit(action: UsageAction) { const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); - // Run both DB calls in parallel - they are fully independent. - const [user, used] = await Promise.all([ - prisma.user.findUnique({ - where: { id: req.user.id }, - select: { subscriptionPlan: true, subscriptionStatus: true, subscriptionEndDate: true }, - }), - prisma.usageLog.count({ + // Atomically check the limit AND create the usage log inside a + // single transaction so concurrent requests cannot both pass. + const result = await prisma.$transaction(async (tx) => { + // Lock the user row so concurrent requests for the same user + // are serialised (prevents the race where two requests both + // read the same count before either writes). + const rows = await tx.$queryRawUnsafe< + { id: string; subscriptionPlan: string; subscriptionStatus: string | null; subscriptionEndDate: Date | null }[] + >( + `SELECT id, "subscriptionPlan", "subscriptionStatus", "subscriptionEndDate" FROM "User" WHERE id = $1 FOR UPDATE`, + req.user.id, + ); + const user = rows?.[0]; + + if (!user) { + return { kind: "no-user" as const }; + } + + const used = await tx.usageLog.count({ where: { userId: req.user.id, action, createdAt: { gte: startOfDay }, }, - }), - ]); + }); - if (!user) { - res.status(401).json({ message: "User not found" }); - return; - } + const tier = getPlanTier(user.subscriptionPlan, user.subscriptionStatus, user.subscriptionEndDate); + const limit = DAILY_LIMITS[action][tier]; - const tier = getPlanTier(user.subscriptionPlan, user.subscriptionStatus, user.subscriptionEndDate); - const limit = DAILY_LIMITS[action][tier]; + if (used >= limit) { + return { kind: "over-limit" as const, used, limit, action, tier }; + } - if (used >= limit) { - res.status(429).json({ - message: tier === "FREE" - ? "Daily limit reached. Upgrade to Premium for higher limits." - : "Daily limit reached. Try again tomorrow.", - usage: { used, limit, action, tier }, - }); - return; - } + // Pre-reserve the slot inside the same transaction — the + // controller will skip its own create call when it sees this flag. + await tx.usageLog.create({ data: { userId: req.user.id, action } }); - (req as any).usageInfo = { used, limit, action, tier }; - next(); + return { kind: "ok" as const, used: used + 1, limit, action, tier }; + }); + + switch (result.kind) { + case "no-user": + res.status(401).json({ message: "User not found" }); + return; + case "over-limit": + res.status(429).json({ + message: result.tier === "FREE" + ? "Daily limit reached. Upgrade to Premium for higher limits." + : "Daily limit reached. Try again tomorrow.", + usage: { used: result.used, limit: result.limit, action: result.action, tier: result.tier }, + }); + return; + case "ok": + (req as any).usageInfo = { used: result.used, limit: result.limit, action: result.action, tier: result.tier }; + (req as any).usageLogCreated = true; + next(); + } } catch (err) { next(err); } diff --git a/server/src/module/ats/ats.controller.ts b/server/src/module/ats/ats.controller.ts index a6b46c61a..a28669255 100644 --- a/server/src/module/ats/ats.controller.ts +++ b/server/src/module/ats/ats.controller.ts @@ -30,10 +30,8 @@ export class AtsController { const score = await this.atsService.scoreResume(req.user.id, result.data); - await prisma.usageLog.create({ data: { userId: req.user.id, action: "ATS_SCORE" } }); - const usage = req.usageInfo - ? { used: req.usageInfo.used + 1, limit: req.usageInfo.limit } + ? { used: req.usageInfo.used, limit: req.usageInfo.limit } : undefined; const isFresh = @@ -84,8 +82,6 @@ export class AtsController { const response = await this.atsService.applySuggestions(req.user.id, result.data); - await prisma.usageLog.create({ data: { userId: req.user.id, action: "GENERATE_RESUME" } }); - res.json(response); } catch (err) { console.error("ATS apply suggestions error:", err); diff --git a/server/src/module/ats/cover-letter.controller.ts b/server/src/module/ats/cover-letter.controller.ts index 77fe5ae95..85ce696f4 100644 --- a/server/src/module/ats/cover-letter.controller.ts +++ b/server/src/module/ats/cover-letter.controller.ts @@ -71,10 +71,8 @@ export class CoverLetterController { targetWords: result.data.targetWords ?? 300, }).catch(() => {}); - await prisma.usageLog.create({ data: { userId: req.user.id, action: "COVER_LETTER" as UsageAction } }); - const usage = req.usageInfo - ? { used: req.usageInfo.used + 1, limit: req.usageInfo.limit } + ? { used: req.usageInfo.used, limit: req.usageInfo.limit } : undefined; res.json({ message: "Cover letter generated successfully", coverLetter, usage }); diff --git a/server/src/module/ats/resume-gen.controller.ts b/server/src/module/ats/resume-gen.controller.ts index 1cd83676c..a2ead16e2 100644 --- a/server/src/module/ats/resume-gen.controller.ts +++ b/server/src/module/ats/resume-gen.controller.ts @@ -71,10 +71,8 @@ export class ResumeGenController { }, }); - await prisma.usageLog.create({ data: { userId: req.user.id, action: "GENERATE_RESUME" as UsageAction } }); - const usage = req.usageInfo - ? { used: req.usageInfo.used + 1, limit: req.usageInfo.limit } + ? { used: req.usageInfo.used, limit: req.usageInfo.limit } : undefined; res.json({ message: "Resume generated successfully", latex, usage }); diff --git a/server/src/module/dsa/dsa.service.ts b/server/src/module/dsa/dsa.service.ts index 0f41967db..6deb51d5f 100644 --- a/server/src/module/dsa/dsa.service.ts +++ b/server/src/module/dsa/dsa.service.ts @@ -782,11 +782,6 @@ Return ONLY a JSON array, no markdown fences: }); } - // Log usage for rate limiting - await prisma.usageLog.create({ - data: { userId: studentId, action: "CODE_RUN" }, - }); - // Track engagement — fire-and-forget, never blocks the submission response void prisma.contentView.create({ data: { diff --git a/server/src/module/job-agent/job-agent.controller.ts b/server/src/module/job-agent/job-agent.controller.ts index 300ae05c7..04da66281 100644 --- a/server/src/module/job-agent/job-agent.controller.ts +++ b/server/src/module/job-agent/job-agent.controller.ts @@ -16,10 +16,6 @@ export class JobAgentController { } const result = await jobAgentService.chat(req.user.id, parsed.data.message); - await prisma.usageLog.create({ - data: { userId: req.user.id, action: "AI_JOB_CHAT" as UsageAction }, - }); - res.json(result); } catch (err) { next(err); } }; @@ -62,11 +58,6 @@ export class JobAgentController { abortController.signal, ); - // Log usage once per request (not per token) - await prisma.usageLog.create({ - data: { userId: req.user.id, action: "AI_JOB_CHAT" as UsageAction }, - }); - send("done", {}); } catch (err) { if (!abortController.signal.aborted) { diff --git a/server/src/module/student/student.controller.ts b/server/src/module/student/student.controller.ts index c66c37338..681c7cc52 100644 --- a/server/src/module/student/student.controller.ts +++ b/server/src/module/student/student.controller.ts @@ -22,8 +22,7 @@ export class StudentController { const application = await this.studentService.applyToJob(jobId, req.user.id, result.data); - await prisma.usageLog.create({ data: { userId: req.user.id, action: "JOB_APPLICATION" } }); - const usage = req.usageInfo ? { used: req.usageInfo.used + 1, limit: req.usageInfo.limit } : undefined; + const usage = req.usageInfo ? { used: req.usageInfo.used, limit: req.usageInfo.limit } : undefined; return res.status(201).json({ message: "Application submitted successfully", application, usage }); } catch (error) { @@ -163,7 +162,7 @@ export class StudentController { if (isNaN(adminJobId)) return res.status(400).json({ message: "Invalid job ID" }); const application = await this.studentService.applyToExternalJob(req.user.id, adminJobId); - const usage = req.usageInfo ? { used: req.usageInfo.used + 1, limit: req.usageInfo.limit } : undefined; + const usage = req.usageInfo ? { used: req.usageInfo.used, limit: req.usageInfo.limit } : undefined; return res.status(201).json({ message: "Applied successfully", application, usage }); } catch (error) { diff --git a/server/src/module/student/student.external-application.service.test.ts b/server/src/module/student/student.external-application.service.test.ts index ddf87404e..529428d81 100644 --- a/server/src/module/student/student.external-application.service.test.ts +++ b/server/src/module/student/student.external-application.service.test.ts @@ -64,10 +64,9 @@ describe("StudentService.applyToExternalJob", () => { mocks.prisma.$transaction.mockImplementation(async (callback) => callback(mocks.tx)); }); - it("creates the application and usage log in one transaction", async () => { + it("creates the application in a transaction", async () => { mocks.prisma.adminJob.findUnique.mockResolvedValue(activeJob); mocks.tx.externalJobApplication.create.mockResolvedValue(createdApplication); - mocks.tx.usageLog.create.mockResolvedValue({ id: 401 }); await expect(service.applyToExternalJob(44, 23)).resolves.toEqual(createdApplication); @@ -80,19 +79,23 @@ describe("StudentService.applyToExternalJob", () => { }, }, }); - expect(mocks.tx.usageLog.create).toHaveBeenCalledWith({ - data: { userId: 44, action: "JOB_APPLICATION" }, - }); expect(mocks.prisma.externalJobApplication.create).not.toHaveBeenCalled(); - expect(mocks.prisma.usageLog.create).not.toHaveBeenCalled(); }); - it("does not run post-commit work when usage logging fails", async () => { + it("runs post-commit work after successful create", async () => { mocks.prisma.adminJob.findUnique.mockResolvedValue(activeJob); mocks.tx.externalJobApplication.create.mockResolvedValue(createdApplication); - mocks.tx.usageLog.create.mockRejectedValue(new Error("Usage log unavailable")); - await expect(service.applyToExternalJob(44, 23)).rejects.toThrow("Usage log unavailable"); + await service.applyToExternalJob(44, 23); + expect(mocks.badgeService.checkAndAwardBadges).toHaveBeenCalled(); + expect(checkApplicationMilestoneSpy).toHaveBeenCalled(); + }); + + it("does not run post-commit work when application fails", async () => { + mocks.prisma.adminJob.findUnique.mockResolvedValue(activeJob); + mocks.tx.externalJobApplication.create.mockRejectedValue(new Error("DB error")); + + await expect(service.applyToExternalJob(44, 23)).rejects.toThrow("DB error"); expect(mocks.badgeService.checkAndAwardBadges).not.toHaveBeenCalled(); expect(checkApplicationMilestoneSpy).not.toHaveBeenCalled(); }); diff --git a/server/src/module/student/student.service.ts b/server/src/module/student/student.service.ts index 2c794ea3f..088c8de81 100644 --- a/server/src/module/student/student.service.ts +++ b/server/src/module/student/student.service.ts @@ -264,9 +264,6 @@ Rules: }, }, }); - await tx.usageLog.create({ - data: { userId: studentId, action: "JOB_APPLICATION" }, - }); return createdApplication; }); diff --git a/server/src/module/upload/upload.controller.ts b/server/src/module/upload/upload.controller.ts index 2ebf9ca10..82b132784 100644 --- a/server/src/module/upload/upload.controller.ts +++ b/server/src/module/upload/upload.controller.ts @@ -197,14 +197,6 @@ export class UploadController { select: { id: true, name: true, email: true, role: true, contactNo: true, profilePic: true, resumes: true, company: true, designation: true, createdAt: true }, }); - // Log daily quota usage for resume generation/upload - await prisma.usageLog.create({ - data: { - userId: req.user.id, - action: "GENERATE_RESUME", - }, - }); - const signedResumes = await signUrls(user.resumes); return res.status(200).json({ message: "Resume updated",