1. replaceAll (Find & Replace) — une seule transaction ProseMirror au lieu d'un forEach cassé. Tous les matchs sont maintenant remplacés. 2. Link Preview unwrap — deleteNode() au lieu de clearer les attrs qui laissaient un nœud fantôme invisible dans le document. 3. Conversion Markdown → richtext — breaks: true dans marked.parse() Les simple newlines sont maintenant convertis en <br>. + préserve les blocs custom (toggle, callout, math, columns, outline, link-preview) en commentaires HTML lors de l'export MD. 4. emitNoteChange exercices — shape corrigée (type:'created' attend un objet Note, pas noteId/notebookId séparés). 5. Raccourcis clavier sans conflit : Cmd+Shift+C → Cmd+Alt+C (callout, avant: copier) Cmd+Shift+O → Cmd+Alt+O (outline, avant: historique/signets) Cmd+Shift+L → Cmd+Alt+L (colonnes, avant: lock screen macOS)
344 lines
9.2 KiB
TypeScript
344 lines
9.2 KiB
TypeScript
import { redis } from './redis';
|
|
import { prisma } from './prisma';
|
|
import { hasAnyActiveByok } from './byok';
|
|
import {
|
|
getCurrentPeriodKey,
|
|
getRedisKey,
|
|
parseRedisInt,
|
|
isValidFeature,
|
|
} from './quota-utils';
|
|
import {
|
|
getLimitAsync,
|
|
getTierFeaturesAsync,
|
|
invalidateEntitlementCache,
|
|
TIER_LIMITS,
|
|
type SubscriptionTier,
|
|
} from './plan-entitlements';
|
|
|
|
export interface EntitlementResult {
|
|
allowed: boolean;
|
|
remaining: number;
|
|
limit: number;
|
|
tier: SubscriptionTier;
|
|
reason?: 'QUOTA_EXCEEDED' | 'TIER_LIMITED' | 'FEATURE_NOT_AVAILABLE';
|
|
message?: string;
|
|
upgradeTier?: 'PRO' | 'BUSINESS';
|
|
byokConfigured?: boolean;
|
|
}
|
|
|
|
export class QuotaExceededError extends Error {
|
|
code = 'QUOTA_EXCEEDED';
|
|
upgradeTier: 'PRO' | 'BUSINESS';
|
|
feature: string;
|
|
currentQuota: number;
|
|
usedQuota: number;
|
|
byokConfigured: boolean;
|
|
billingOwnerId?: string;
|
|
triggeredByUserId?: string;
|
|
isGuestActor?: boolean;
|
|
currentTier?: string;
|
|
|
|
constructor(
|
|
upgradeTier: 'PRO' | 'BUSINESS',
|
|
feature: string,
|
|
currentQuota: number,
|
|
usedQuota: number,
|
|
byokConfigured: boolean = false,
|
|
sessionMeta?: {
|
|
billingOwnerId?: string;
|
|
triggeredByUserId?: string;
|
|
isGuestActor?: boolean;
|
|
currentTier?: string;
|
|
},
|
|
) {
|
|
super(`Quota exceeded for ${feature}`);
|
|
this.upgradeTier = upgradeTier;
|
|
this.feature = feature;
|
|
this.currentQuota = currentQuota;
|
|
this.usedQuota = usedQuota;
|
|
this.byokConfigured = byokConfigured;
|
|
this.billingOwnerId = sessionMeta?.billingOwnerId;
|
|
this.triggeredByUserId = sessionMeta?.triggeredByUserId;
|
|
this.isGuestActor = sessionMeta?.isGuestActor;
|
|
this.currentTier = sessionMeta?.currentTier;
|
|
}
|
|
|
|
toJSON() {
|
|
return {
|
|
error: this.code,
|
|
feature: this.feature,
|
|
upgradeTier: this.upgradeTier,
|
|
byokConfigured: this.byokConfigured,
|
|
isGuestActor: this.isGuestActor ?? false,
|
|
currentTier: this.currentTier,
|
|
};
|
|
}
|
|
}
|
|
|
|
const TTL_SECONDS = 90 * 24 * 60 * 60;
|
|
|
|
const INCREMENT_BY_LUA = `
|
|
local count = tonumber(ARGV[1]) or 1
|
|
local ttl = tonumber(ARGV[2])
|
|
redis.call('INCRBY', KEYS[1], count)
|
|
local ttlResult = redis.call('TTL', KEYS[1])
|
|
if ttlResult == -1 then
|
|
redis.call('EXPIRE', KEYS[1], ttl)
|
|
end
|
|
local newCount = tonumber(redis.call('GET', KEYS[1]))
|
|
return newCount
|
|
`;
|
|
|
|
const RESERVE_LUA = `
|
|
local limit = tonumber(ARGV[1])
|
|
local ttl = tonumber(ARGV[2])
|
|
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
|
|
if current >= limit then
|
|
return -1
|
|
end
|
|
redis.call('INCRBY', KEYS[1], 1)
|
|
local ttlResult = redis.call('TTL', KEYS[1])
|
|
if ttlResult == -1 then
|
|
redis.call('EXPIRE', KEYS[1], ttl)
|
|
end
|
|
local newCount = tonumber(redis.call('GET', KEYS[1]))
|
|
return newCount
|
|
`;
|
|
|
|
export async function getUserInfo(
|
|
userId: string,
|
|
): Promise<{ tier: SubscriptionTier; status: string; currentPeriodEnd?: Date }> {
|
|
const subscription = await prisma.subscription.findUnique({
|
|
where: { userId },
|
|
});
|
|
if (!subscription) return { tier: 'BASIC', status: 'INACTIVE' };
|
|
return {
|
|
tier: subscription.tier as SubscriptionTier,
|
|
status: subscription.status,
|
|
currentPeriodEnd: subscription.currentPeriodEnd,
|
|
};
|
|
}
|
|
|
|
export async function getEffectiveTier(
|
|
userId: string,
|
|
): Promise<SubscriptionTier> {
|
|
const { tier, status, currentPeriodEnd } = await getUserInfo(userId);
|
|
|
|
if (status === 'ACTIVE' || status === 'TRIALING') return tier;
|
|
|
|
if ((status === 'PAST_DUE' || status === 'CANCELED') && currentPeriodEnd) {
|
|
if (new Date() < new Date(currentPeriodEnd)) return tier;
|
|
}
|
|
|
|
return 'BASIC';
|
|
}
|
|
|
|
export async function canUseFeature(
|
|
userId: string,
|
|
feature: string,
|
|
): Promise<EntitlementResult> {
|
|
if (!isValidFeature(feature)) {
|
|
return {
|
|
allowed: false,
|
|
remaining: 0,
|
|
limit: 0,
|
|
tier: 'BASIC',
|
|
reason: 'TIER_LIMITED',
|
|
message: `Unknown feature: ${feature}`,
|
|
};
|
|
}
|
|
|
|
const tier = await getEffectiveTier(userId);
|
|
const limit = await getLimitAsync(tier, feature);
|
|
|
|
if (limit === undefined) {
|
|
return {
|
|
allowed: false,
|
|
remaining: 0,
|
|
limit: 0,
|
|
tier,
|
|
reason: 'FEATURE_NOT_AVAILABLE',
|
|
message: `Feature "${feature}" is not available on the ${tier} plan. Upgrade to unlock it.`,
|
|
upgradeTier: tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
|
};
|
|
}
|
|
|
|
if (limit === Infinity) {
|
|
return { allowed: true, remaining: Infinity, limit: Infinity, tier };
|
|
}
|
|
|
|
try {
|
|
const key = getRedisKey(userId, feature);
|
|
const currentStr = await redis.get(key);
|
|
const current = parseRedisInt(currentStr);
|
|
const allowed = current < limit;
|
|
|
|
if (!allowed) {
|
|
const byokConfigured = await hasAnyActiveByok(userId);
|
|
return {
|
|
allowed: false,
|
|
remaining: 0,
|
|
limit,
|
|
tier,
|
|
reason: 'QUOTA_EXCEEDED',
|
|
upgradeTier: tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
|
byokConfigured,
|
|
};
|
|
}
|
|
|
|
return {
|
|
allowed: true,
|
|
remaining: Math.max(0, limit - current),
|
|
limit,
|
|
tier,
|
|
byokConfigured: await hasAnyActiveByok(userId),
|
|
};
|
|
} catch (err) {
|
|
console.error('[entitlements] Redis unavailable, allowing request (fail-open):', err);
|
|
return { allowed: true, remaining: limit, limit, tier };
|
|
}
|
|
}
|
|
|
|
export async function checkEntitlementOrThrow(
|
|
userId: string,
|
|
feature: string,
|
|
): Promise<void> {
|
|
const result = await canUseFeature(userId, feature);
|
|
|
|
if (!result.allowed) {
|
|
throw new QuotaExceededError(
|
|
result.upgradeTier ?? (result.tier === 'BASIC' ? 'PRO' : 'BUSINESS'),
|
|
feature,
|
|
result.limit,
|
|
result.limit > 0 ? result.limit - result.remaining : 0,
|
|
result.byokConfigured ?? false,
|
|
{ currentTier: result.tier },
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function reserveUsageOrThrow(
|
|
userId: string,
|
|
feature: string,
|
|
): Promise<void> {
|
|
if (!isValidFeature(feature)) {
|
|
throw new QuotaExceededError('PRO', feature, 0, 0, false, { currentTier: 'BASIC' });
|
|
}
|
|
|
|
const tier = await getEffectiveTier(userId);
|
|
const limit = await getLimitAsync(tier, feature);
|
|
|
|
if (limit === undefined) {
|
|
throw new QuotaExceededError(
|
|
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
|
feature,
|
|
0,
|
|
0,
|
|
false,
|
|
{ currentTier: tier },
|
|
);
|
|
}
|
|
|
|
if (limit === Infinity) return;
|
|
|
|
try {
|
|
const key = getRedisKey(userId, feature);
|
|
const newCount = await redis.eval(
|
|
RESERVE_LUA,
|
|
1,
|
|
key,
|
|
String(limit),
|
|
String(TTL_SECONDS),
|
|
) as number;
|
|
|
|
if (newCount === -1) {
|
|
const byokConfigured = await hasAnyActiveByok(userId);
|
|
throw new QuotaExceededError(
|
|
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
|
feature,
|
|
limit,
|
|
limit,
|
|
byokConfigured,
|
|
{ currentTier: tier },
|
|
);
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof QuotaExceededError) throw err;
|
|
console.error('[entitlements] Redis unavailable, allowing request (fail-open):', err);
|
|
}
|
|
}
|
|
|
|
/** Host-pays: bill session owner, attach actor metadata for 402 responses (Story 3.4). */
|
|
export async function checkSessionEntitlementOrThrow(
|
|
billingOwnerId: string,
|
|
triggeredByUserId: string,
|
|
isGuestActor: boolean,
|
|
feature: string,
|
|
): Promise<void> {
|
|
try {
|
|
await reserveUsageOrThrow(billingOwnerId, feature);
|
|
} catch (err) {
|
|
if (err instanceof QuotaExceededError) {
|
|
err.billingOwnerId = billingOwnerId;
|
|
err.triggeredByUserId = triggeredByUserId;
|
|
err.isGuestActor = isGuestActor;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export function incrementUsageAsync(userId: string, feature: string, count: number = 1): Promise<void> {
|
|
if (!isValidFeature(feature)) return Promise.resolve();
|
|
|
|
const key = getRedisKey(userId, feature);
|
|
return redis.eval(INCREMENT_BY_LUA, 1, key, String(count), String(TTL_SECONDS)).then(() => {}).catch((err) => {
|
|
console.error('[entitlements] Async increment failed:', err);
|
|
});
|
|
}
|
|
|
|
export async function getUserQuotas(
|
|
userId: string,
|
|
): Promise<Record<string, { remaining: number; limit: number; used: number }>> {
|
|
const tier = await getEffectiveTier(userId);
|
|
const features = await getTierFeaturesAsync(tier);
|
|
const period = getCurrentPeriodKey();
|
|
|
|
if (features.length === 0) return {};
|
|
|
|
const keys = features.map((f) => `usage:${userId}:${f}:${period}`);
|
|
|
|
try {
|
|
const values = await redis.mget(...keys);
|
|
|
|
const result: Record<string, { remaining: number; limit: number; used: number }> = {};
|
|
for (let i = 0; i < features.length; i++) {
|
|
const feature = features[i];
|
|
const limit = (await getLimitAsync(tier, feature)) ?? 0;
|
|
const current = parseRedisInt(values[i]);
|
|
result[feature] = {
|
|
remaining: limit === Infinity ? Infinity : Math.max(0, limit - current),
|
|
limit,
|
|
used: current,
|
|
};
|
|
}
|
|
|
|
return result;
|
|
} catch (err) {
|
|
console.error('[entitlements] getUserQuotas Redis error:', err);
|
|
const result: Record<string, { remaining: number; limit: number; used: number }> = {};
|
|
for (const feature of features) {
|
|
const limit = (await getLimitAsync(tier, feature)) ?? 0;
|
|
result[feature] = { remaining: limit, limit, used: 0 };
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
|
|
export { TIER_LIMITS, invalidateEntitlementCache };
|
|
export type { SubscriptionTier };
|
|
|
|
/** @deprecated Use getLimitAsync — sync helper for tests only */
|
|
export async function getLimit(tier: SubscriptionTier, feature: string): Promise<number | undefined> {
|
|
return getLimitAsync(tier, feature);
|
|
}
|