Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 48 additions & 26 deletions server/src/middleware/usage-limit.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
6 changes: 1 addition & 5 deletions server/src/module/ats/ats.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 1 addition & 3 deletions server/src/module/ats/cover-letter.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
4 changes: 1 addition & 3 deletions server/src/module/ats/resume-gen.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
5 changes: 0 additions & 5 deletions server/src/module/dsa/dsa.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
9 changes: 0 additions & 9 deletions server/src/module/job-agent/job-agent.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
};
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 2 additions & 3 deletions server/src/module/student/student.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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();
});
Expand Down
3 changes: 0 additions & 3 deletions server/src/module/student/student.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,9 +264,6 @@ Rules:
},
},
});
await tx.usageLog.create({
data: { userId: studentId, action: "JOB_APPLICATION" },
});
return createdApplication;
});

Expand Down
8 changes: 0 additions & 8 deletions server/src/module/upload/upload.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading