Skip to content
Merged
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
13 changes: 8 additions & 5 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,21 @@ ZENPDF_FREE_ACCOUNT_MAX_MB_PER_FILE=
ZENPDF_FREE_ACCOUNT_MAX_CONCURRENT_JOBS=
ZENPDF_FREE_ACCOUNT_MAX_JOBS_PER_DAY=
ZENPDF_FREE_ACCOUNT_MAX_DAILY_MINUTES=
ZENPDF_PREMIUM_MAX_FILES_PER_JOB=
ZENPDF_PREMIUM_MAX_MB_PER_FILE=
ZENPDF_PREMIUM_MAX_CONCURRENT_JOBS=
ZENPDF_PREMIUM_MAX_JOBS_PER_DAY=
ZENPDF_PREMIUM_MAX_DAILY_MINUTES=
ZENPDF_GLOBAL_MAX_CONCURRENT_JOBS=
ZENPDF_GLOBAL_MAX_JOBS_PER_DAY=
ZENPDF_GLOBAL_MAX_DAILY_MINUTES=
ZENPDF_GLOBAL_JOB_MAX_ATTEMPTS=
ZENPDF_GLOBAL_LEASE_DURATION_MS=
ZENPDF_GLOBAL_ARTIFACT_TTL_HOURS=
ZENPDF_WORKER_TOKEN=
NEXT_PUBLIC_DONATE_UPI_ID=
NEXT_PUBLIC_DONATE_PAYEE_NAME=ZenPDF
# Legacy key still supported by app fallback: NEXT_PUBLIC_DONATE_UPI_NAME
NEXT_PUBLIC_DONATE_UPI_NOTE="Support ZenPDF"
NEXT_PUBLIC_DONATE_UPI_QR_URL=
NEXT_PUBLIC_DONATE_ICON_LIGHT=
NEXT_PUBLIC_DONATE_ICON_DARK=
NEXT_PUBLIC_DONATE_CARD_EMBED_URL=
NEXT_PUBLIC_COMPRESS_TIMEOUT_BASE_SECONDS=120
NEXT_PUBLIC_COMPRESS_TIMEOUT_MAX_SECONDS=900
NEXT_PUBLIC_COMPRESS_TIMEOUT_PER_MB_SECONDS=3
Expand Down
14 changes: 11 additions & 3 deletions apps/web/convex/capacity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,33 +35,41 @@ export const getUsageSnapshot = query({
const { userId, tier } = await resolveUser(ctx);
const anonId = args.anonId?.trim() || undefined;
const resolvedAnonId = userId ? undefined : anonId;
const [planLimits, anonLimits, freeLimits, premiumLimits, budget, usageResult] =
const [planLimits, anonLimits, freeLimits, globalLimits, budget, usageResult, globalUsageResult] =
await Promise.all([
resolvePlanLimits(ctx, tier),
resolvePlanLimits(ctx, "ANON"),
resolvePlanLimits(ctx, "FREE_ACCOUNT"),
resolvePlanLimits(ctx, "PREMIUM"),
resolveGlobalLimits(ctx),
resolveBudgetState(ctx, now),
resolveUsageCounter(ctx, userId, resolvedAnonId, now),
resolveGlobalUsageCounter(ctx, now),
]);
const planSnapshot = {
ANON: anonLimits,
FREE_ACCOUNT: freeLimits,
PREMIUM: premiumLimits,
};
const { counter, periodStart } = usageResult;
const { counter: globalCounter, periodStart: globalPeriodStart } = globalUsageResult;

return {
tier,
planLimits,
plans: planSnapshot,
globalLimits,
budget,
usage: counter ?? {
periodStart,
jobsUsed: 0,
minutesUsed: 0,
bytesProcessed: 0,
},
globalUsage: globalCounter ?? {
periodStart: globalPeriodStart,
jobsUsed: 0,
minutesUsed: 0,
bytesProcessed: 0,
},
};
},
});
45 changes: 8 additions & 37 deletions apps/web/convex/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,48 +10,22 @@ type Ctx = MutationCtx | QueryCtx;
export type ResolvedUser = {
identity: UserIdentity | null;
userId: Id<"users"> | undefined;
tier: "ANON" | "FREE_ACCOUNT" | "PREMIUM";
adsFree: boolean;
tier: "ANON" | "FREE_ACCOUNT";
};

const parseEnvList = (value: string | undefined) =>
value
?.split(",")
.map((entry) => entry.trim())
.filter(Boolean) ?? [];

const resolveTier = (identity: UserIdentity | null, storedTier?: ResolvedUser["tier"]) => {
if (!identity) {
return { tier: "ANON" as const, adsFree: false };
}

const premiumEmails = parseEnvList(process.env.ZENPDF_PREMIUM_EMAILS).map((email) =>
email.toLowerCase(),
);
const premiumClerkIds = parseEnvList(process.env.ZENPDF_PREMIUM_CLERK_IDS);
const hasAllowlist = premiumEmails.length > 0 || premiumClerkIds.length > 0;
const email = normalizeOptionalEmail(identity.email);
const isPremium =
(email ? premiumEmails.includes(email) : false) ||
premiumClerkIds.includes(identity.subject);

if (isPremium) {
return { tier: "PREMIUM" as const, adsFree: true };
return { tier: "ANON" as const };
}

if (hasAllowlist) {
return { tier: "FREE_ACCOUNT" as const, adsFree: false };
}

const tier = storedTier ?? "FREE_ACCOUNT";
return { tier, adsFree: tier === "PREMIUM" };
return { tier };
};

export const resolveUser = async (ctx: Ctx): Promise<ResolvedUser> => {
const identity = await ctx.auth.getUserIdentity();

if (!identity) {
return { identity: null, userId: undefined, tier: "ANON", adsFree: false };
return { identity: null, userId: undefined, tier: "ANON" };
}

const clerkUserId = identity.subject;
Expand All @@ -60,13 +34,13 @@ export const resolveUser = async (ctx: Ctx): Promise<ResolvedUser> => {
.withIndex("by_clerk_id", (q) => q.eq("clerkUserId", clerkUserId))
.unique();

const { tier, adsFree } = resolveTier(identity, existing?.tier);
const { tier } = resolveTier(identity, existing?.tier);

if (existing) {
return { identity, userId: existing._id, tier, adsFree };
return { identity, userId: existing._id, tier };
}

return { identity, userId: undefined, tier, adsFree };
return { identity, userId: undefined, tier };
};

export const resolveOrCreateUser = async (
Expand All @@ -84,7 +58,6 @@ export const resolveOrCreateUser = async (
email: normalizeOptionalEmail(resolved.identity.email),
name: resolved.identity.name ?? resolved.identity.nickname,
tier: resolved.tier,
adsFree: resolved.adsFree,
createdAt: now,
updatedAt: now,
});
Expand All @@ -93,11 +66,9 @@ export const resolveOrCreateUser = async (
}

const existing = await ctx.db.get(resolved.userId);
const storedAdsFree = existing?.adsFree ?? false;
if (existing && (existing.tier !== resolved.tier || storedAdsFree !== resolved.adsFree)) {
if (existing && existing.tier !== resolved.tier) {
await ctx.db.patch(resolved.userId, {
tier: resolved.tier,
adsFree: resolved.adsFree,
updatedAt: now,
});
}
Expand Down
23 changes: 8 additions & 15 deletions apps/web/convex/lib/limits.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { MutationCtx, QueryCtx } from "../_generated/server";

export type PlanTier = "ANON" | "FREE_ACCOUNT" | "PREMIUM";
export type PlanTier = "ANON" | "FREE_ACCOUNT";

export type PlanLimits = {
maxFilesPerJob: number;
Expand All @@ -22,31 +22,24 @@ export type GlobalLimits = {
const DEFAULT_PLAN_LIMITS: Record<PlanTier, PlanLimits> = {
ANON: {
maxFilesPerJob: 1,
maxMbPerFile: 10,
maxMbPerFile: 25,
maxConcurrentJobs: 1,
maxJobsPerDay: 3,
maxJobsPerDay: 5,
maxDailyMinutes: 10,
},
FREE_ACCOUNT: {
maxFilesPerJob: 3,
maxMbPerFile: 50,
maxMbPerFile: 75,
maxConcurrentJobs: 2,
maxJobsPerDay: 25,
maxJobsPerDay: 30,
maxDailyMinutes: 60,
},
PREMIUM: {
maxFilesPerJob: 10,
maxMbPerFile: 250,
maxConcurrentJobs: 4,
maxJobsPerDay: 200,
maxDailyMinutes: 240,
},
};

const DEFAULT_GLOBAL_LIMITS: GlobalLimits = {
maxConcurrentJobs: 20,
maxJobsPerDay: 1500,
maxDailyMinutes: 12000,
maxConcurrentJobs: 3,
maxJobsPerDay: 200,
maxDailyMinutes: 120,
jobMaxAttempts: 3,
leaseDurationMs: 2 * 60 * 1000,
artifactTtlHours: 24,
Expand Down
4 changes: 2 additions & 2 deletions apps/web/convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { v } from "convex/values";
const tier = v.union(
v.literal("ANON"),
v.literal("FREE_ACCOUNT"),
v.literal("PREMIUM"),
);

const jobStatus = v.union(
Expand All @@ -20,8 +19,9 @@ export default defineSchema({
clerkUserId: v.string(),
email: v.optional(v.string()),
name: v.optional(v.string()),
tier,
// Legacy field kept optional so existing records remain schema-valid.
adsFree: v.optional(v.boolean()),
tier,
createdAt: v.number(),
updatedAt: v.number(),
})
Expand Down
3 changes: 1 addition & 2 deletions apps/web/convex/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@ import { resolveUser } from "./lib/auth";
export const getViewer = query({
args: {},
handler: async (ctx) => {
const { identity, tier, adsFree } = await resolveUser(ctx);
const { identity, tier } = await resolveUser(ctx);
return {
tier,
adsFree,
signedIn: Boolean(identity),
};
},
Expand Down
2 changes: 2 additions & 0 deletions apps/web/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
// Convex generated code should not be linted.
"convex/_generated/**",
]),
]);

Expand Down
Loading