From ef46cdbe2968a80db81f76b1602beb967792f262 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 12:00:07 +0500 Subject: [PATCH] Add paid-access and usage limit helpers for clean-pdf --- src/lib/clean-pdf/__tests__/access.test.ts | 43 ++++++++++++++++ src/lib/clean-pdf/access.ts | 60 ++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 src/lib/clean-pdf/__tests__/access.test.ts create mode 100644 src/lib/clean-pdf/access.ts diff --git a/src/lib/clean-pdf/__tests__/access.test.ts b/src/lib/clean-pdf/__tests__/access.test.ts new file mode 100644 index 0000000..1910569 --- /dev/null +++ b/src/lib/clean-pdf/__tests__/access.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { decidePaidAccess, monthStartUtc } from "@/lib/clean-pdf/access"; + +const now = new Date("2026-07-06T10:00:00Z"); +const freeSlug = "obsidian-start"; + +describe("decidePaidAccess", () => { + it("платный enrollment без срока — да", () => { + expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now, + enrollments: [{ courseSlug: "zotero", expiresAt: null }] })).toBe(true); + }); + + it("только бесплатный курс — нет", () => { + expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now, + enrollments: [{ courseSlug: "obsidian-start", expiresAt: null }] })).toBe(false); + }); + + it("без enrollments — нет", () => { + expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now, enrollments: [] })).toBe(false); + }); + + it("истёкший платный — нет, живой срок — да", () => { + expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now, + enrollments: [{ courseSlug: "zotero", expiresAt: new Date("2026-01-01") }] })).toBe(false); + expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now, + enrollments: [{ courseSlug: "zotero", expiresAt: new Date("2027-01-01") }] })).toBe(true); + }); + + it("admin и curator — да даже без курсов", () => { + expect(decidePaidAccess({ role: "admin", banned: false, freeSlug, now, enrollments: [] })).toBe(true); + expect(decidePaidAccess({ role: "curator", banned: false, freeSlug, now, enrollments: [] })).toBe(true); + }); + + it("бан всё перекрывает", () => { + expect(decidePaidAccess({ role: "admin", banned: true, freeSlug, now, enrollments: [] })).toBe(false); + }); +}); + +describe("monthStartUtc", () => { + it("возвращает первое число месяца 00:00 UTC", () => { + expect(monthStartUtc(new Date("2026-07-06T23:59:59Z")).toISOString()).toBe("2026-07-01T00:00:00.000Z"); + }); +}); diff --git a/src/lib/clean-pdf/access.ts b/src/lib/clean-pdf/access.ts new file mode 100644 index 0000000..a56b3eb --- /dev/null +++ b/src/lib/clean-pdf/access.ts @@ -0,0 +1,60 @@ +import { prisma } from "@/lib/prisma"; + +// НЕ "clean-pdf": так CopyButton логирует копирования — они не должны тратить лимит +export const PDF_TOOL_ID = "clean-pdf-generate"; +export const BURST_LIMIT = 5; // успешных генераций в минуту +const BURST_WINDOW_MS = 60_000; + +export function decidePaidAccess(input: { + role: string; + banned: boolean; + enrollments: { courseSlug: string; expiresAt: Date | null }[]; + freeSlug: string; + now?: Date; +}): boolean { + const now = input.now ?? new Date(); + if (input.banned) return false; + if (input.role === "admin" || input.role === "curator") return true; + return input.enrollments.some( + (e) => e.courseSlug !== input.freeSlug && (e.expiresAt === null || e.expiresAt > now), + ); +} + +export async function hasPaidAccess(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + role: true, + banned: true, + enrollments: { select: { expiresAt: true, course: { select: { slug: true } } } }, + }, + }); + if (!user) return false; + return decidePaidAccess({ + role: user.role, + banned: user.banned ?? false, + freeSlug: process.env.FREE_COURSE_SLUG ?? "obsidian-start", + enrollments: user.enrollments.map((e) => ({ courseSlug: e.course.slug, expiresAt: e.expiresAt })), + }); +} + +export function monthStartUtc(now: Date): Date { + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); +} + +export function getMonthlyLimit(): number { + const n = Number(process.env.PDF_MONTHLY_LIMIT); + return Number.isFinite(n) && n > 0 ? n : 100; +} + +export async function getMonthlyUsage(userId: string): Promise { + return prisma.toolUsage.count({ + where: { userId, tool: PDF_TOOL_ID, createdAt: { gte: monthStartUtc(new Date()) } }, + }); +} + +export async function getBurstUsage(userId: string): Promise { + return prisma.toolUsage.count({ + where: { userId, tool: PDF_TOOL_ID, createdAt: { gte: new Date(Date.now() - BURST_WINDOW_MS) } }, + }); +}