feat: add GET /v1/usage/me endpoint for user-facing usage stats
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 12m41s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 12m41s
This commit is contained in:
parent
2b4fa0c690
commit
dd337d30b5
4 changed files with 145 additions and 2 deletions
|
|
@ -91,4 +91,5 @@ vi.mock("../middleware/usage.js", () => ({
|
|||
usageMiddleware: vi.fn((_req: any, _res: any, next: any) => next()),
|
||||
loadUsageData: vi.fn().mockResolvedValue(undefined),
|
||||
getUsageStats: vi.fn().mockReturnValue({ totalRequests: 0, keys: {} }),
|
||||
getUsageForKey: vi.fn().mockReturnValue({ count: 0, monthKey: "2026-01" }),
|
||||
}));
|
||||
|
|
|
|||
89
src/__tests__/usage-me.test.ts
Normal file
89
src/__tests__/usage-me.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import request from "supertest";
|
||||
|
||||
import { isProKey, isValidKey, getKeyInfo } from "../services/keys.js";
|
||||
import { getUsageForKey } from "../middleware/usage.js";
|
||||
import { app } from "../index.js";
|
||||
|
||||
describe("GET /v1/usage/me", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 401 without auth", async () => {
|
||||
const res = await request(app).get("/v1/usage/me");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns usage for authenticated Pro key", async () => {
|
||||
vi.mocked(isProKey).mockReturnValue(true);
|
||||
vi.mocked(isValidKey).mockReturnValue(true);
|
||||
vi.mocked(getKeyInfo).mockReturnValue({
|
||||
key: "test-key", tier: "pro", email: "test@docfast.dev", createdAt: new Date().toISOString(),
|
||||
} as any);
|
||||
vi.mocked(getUsageForKey).mockReturnValue({ count: 142, monthKey: "2026-03" });
|
||||
|
||||
const res = await request(app)
|
||||
.get("/v1/usage/me")
|
||||
.set("X-API-Key", "test-key");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
used: 142,
|
||||
limit: 5000,
|
||||
plan: "pro",
|
||||
month: "2026-03",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns count 0 for authenticated key with no usage this month", async () => {
|
||||
vi.mocked(isProKey).mockReturnValue(true);
|
||||
vi.mocked(isValidKey).mockReturnValue(true);
|
||||
vi.mocked(getKeyInfo).mockReturnValue({
|
||||
key: "test-key", tier: "pro", email: "test@docfast.dev", createdAt: new Date().toISOString(),
|
||||
} as any);
|
||||
vi.mocked(getUsageForKey).mockReturnValue({ count: 0, monthKey: "2026-03" });
|
||||
|
||||
const res = await request(app)
|
||||
.get("/v1/usage/me")
|
||||
.set("X-API-Key", "test-key");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.used).toBe(0);
|
||||
expect(res.body.limit).toBe(5000);
|
||||
expect(res.body.plan).toBe("pro");
|
||||
});
|
||||
|
||||
it("returns correct month string", async () => {
|
||||
vi.mocked(isValidKey).mockReturnValue(true);
|
||||
vi.mocked(isProKey).mockReturnValue(true);
|
||||
vi.mocked(getKeyInfo).mockReturnValue({
|
||||
key: "test-key", tier: "pro", email: "test@docfast.dev", createdAt: new Date().toISOString(),
|
||||
} as any);
|
||||
vi.mocked(getUsageForKey).mockReturnValue({ count: 5, monthKey: "2026-03" });
|
||||
|
||||
const res = await request(app)
|
||||
.get("/v1/usage/me")
|
||||
.set("X-API-Key", "test-key");
|
||||
|
||||
expect(res.body.month).toBe("2026-03");
|
||||
});
|
||||
|
||||
it("returns demo plan for non-pro keys", async () => {
|
||||
vi.mocked(isProKey).mockReturnValue(false);
|
||||
vi.mocked(isValidKey).mockReturnValue(true);
|
||||
vi.mocked(getKeyInfo).mockReturnValue({
|
||||
key: "test-key", tier: "free", email: "test@docfast.dev", createdAt: new Date().toISOString(),
|
||||
} as any);
|
||||
vi.mocked(getUsageForKey).mockReturnValue({ count: 50, monthKey: "2026-03" });
|
||||
|
||||
const res = await request(app)
|
||||
.get("/v1/usage/me")
|
||||
.set("X-API-Key", "test-key");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.plan).toBe("demo");
|
||||
expect(res.body.limit).toBe(100);
|
||||
expect(res.body.used).toBe(50);
|
||||
});
|
||||
});
|
||||
48
src/index.ts
48
src/index.ts
|
|
@ -19,10 +19,10 @@ import { emailChangeRouter } from "./routes/email-change.js";
|
|||
import { billingRouter } from "./routes/billing.js";
|
||||
import { authMiddleware } from "./middleware/auth.js";
|
||||
import { usageMiddleware, loadUsageData, flushDirtyEntries } from "./middleware/usage.js";
|
||||
import { getUsageStats } from "./middleware/usage.js";
|
||||
import { getUsageStats, getUsageForKey } from "./middleware/usage.js";
|
||||
import { pdfRateLimitMiddleware, getConcurrencyStats } from "./middleware/pdfRateLimit.js";
|
||||
import { initBrowser, closeBrowser } from "./services/browser.js";
|
||||
import { loadKeys, getAllKeys } from "./services/keys.js";
|
||||
import { loadKeys, getAllKeys, isProKey } from "./services/keys.js";
|
||||
import { verifyToken, loadVerifications } from "./services/verification.js";
|
||||
import { initDatabase, pool, cleanupStaleData } from "./services/db.js";
|
||||
import { swaggerSpec } from "./swagger.js";
|
||||
|
|
@ -142,6 +142,50 @@ const convertBodyLimit = express.json({ limit: "500kb" });
|
|||
app.use("/v1/convert", convertBodyLimit, authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter);
|
||||
app.use("/v1/templates", defaultJsonParser, authMiddleware, usageMiddleware, templatesRouter);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/usage/me:
|
||||
* get:
|
||||
* summary: Get your current month's usage
|
||||
* description: Returns the authenticated user's PDF generation usage for the current billing month.
|
||||
* security:
|
||||
* - ApiKeyAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Current usage statistics
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* used:
|
||||
* type: integer
|
||||
* description: Number of PDFs generated this month
|
||||
* limit:
|
||||
* type: integer
|
||||
* description: Monthly PDF limit for your plan
|
||||
* plan:
|
||||
* type: string
|
||||
* enum: [pro, demo]
|
||||
* description: Your current plan
|
||||
* month:
|
||||
* type: string
|
||||
* description: Current billing month (YYYY-MM)
|
||||
* 401:
|
||||
* description: Missing or invalid API key
|
||||
*/
|
||||
app.get("/v1/usage/me", authMiddleware, (req: any, res: any) => {
|
||||
const key = req.apiKeyInfo.key;
|
||||
const { count, monthKey } = getUsageForKey(key);
|
||||
const pro = isProKey(key);
|
||||
res.json({
|
||||
used: count,
|
||||
limit: pro ? 5000 : 100,
|
||||
plan: pro ? "pro" : "demo",
|
||||
month: monthKey,
|
||||
});
|
||||
});
|
||||
|
||||
// Admin: usage stats (admin key required)
|
||||
const adminAuth = (req: any, res: any, next: any) => {
|
||||
const adminKey = process.env.ADMIN_API_KEY;
|
||||
|
|
|
|||
|
|
@ -117,6 +117,15 @@ function trackUsage(key: string, monthKey: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
export function getUsageForKey(key: string): { count: number; monthKey: string } {
|
||||
const monthKey = getMonthKey();
|
||||
const record = usage.get(key);
|
||||
if (record && record.monthKey === monthKey) {
|
||||
return { count: record.count, monthKey };
|
||||
}
|
||||
return { count: 0, monthKey };
|
||||
}
|
||||
|
||||
export function getUsageStats(apiKey?: string): Record<string, { count: number; month: string }> {
|
||||
const stats: Record<string, { count: number; month: string }> = {};
|
||||
if (apiKey) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue