fix: 5 bugs critiques de l'éditeur (Phase 1 audit)
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m39s
CI / Deploy production (on server) (push) Successful in 22s

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)
This commit is contained in:
Antigravity
2026-06-20 15:48:18 +00:00
parent 5b13a88b72
commit ee70e74bf5
51 changed files with 1483 additions and 252 deletions

View File

@@ -7,8 +7,13 @@ import {
parseRedisInt,
isValidFeature,
} from './quota-utils';
type SubscriptionTier = 'BASIC' | 'PRO' | 'BUSINESS' | 'ENTERPRISE';
import {
getLimitAsync,
getTierFeaturesAsync,
invalidateEntitlementCache,
TIER_LIMITS,
type SubscriptionTier,
} from './plan-entitlements';
export interface EntitlementResult {
allowed: boolean;
@@ -70,79 +75,8 @@ export class QuotaExceededError extends Error {
}
}
const TIER_LIMITS: Record<SubscriptionTier, Record<string, number | 'unlimited'>> = {
BASIC: {
semantic_search: 30,
auto_tag: 15,
auto_title: 5,
brainstorm_create: 1,
brainstorm_expand: 10,
brainstorm_enrich: 20,
suggest_charts: 5,
ai_flashcard: 5,
voice_transcribe: 20,
},
PRO: {
semantic_search: 200,
auto_tag: 500,
auto_title: 200,
reformulate: 50,
chat: 50,
brainstorm_create: 5,
brainstorm_expand: 100,
brainstorm_enrich: 200,
suggest_charts: 50,
slide_generate: 20,
excalidraw_generate: 20,
ai_flashcard: 100,
voice_transcribe: 500,
},
BUSINESS: {
semantic_search: 1000,
auto_tag: 1000,
auto_title: 1000,
reformulate: 500,
chat: 500,
brainstorm_create: 'unlimited',
brainstorm_expand: 500,
brainstorm_enrich: 1000,
suggest_charts: 200,
slide_generate: 100,
excalidraw_generate: 100,
ai_flashcard: 'unlimited',
voice_transcribe: 'unlimited',
},
ENTERPRISE: {
semantic_search: 'unlimited',
auto_tag: 'unlimited',
auto_title: 'unlimited',
reformulate: 'unlimited',
chat: 'unlimited',
brainstorm_create: 'unlimited',
brainstorm_expand: 'unlimited',
brainstorm_enrich: 'unlimited',
suggest_charts: 'unlimited',
slide_generate: 'unlimited',
excalidraw_generate: 'unlimited',
ai_flashcard: 'unlimited',
voice_transcribe: 'unlimited',
},
};
const TTL_SECONDS = 90 * 24 * 60 * 60;
const INCREMENT_LUA = `
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
local ttl = tonumber(ARGV[1])
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
`;
const INCREMENT_BY_LUA = `
local count = tonumber(ARGV[1]) or 1
local ttl = tonumber(ARGV[2])
@@ -171,14 +105,6 @@ local newCount = tonumber(redis.call('GET', KEYS[1]))
return newCount
`;
function getLimit(tier: SubscriptionTier, feature: string): number | undefined {
const tierLimits = TIER_LIMITS[tier];
const limit = tierLimits?.[feature];
if (limit === 'unlimited') return Infinity;
if (limit === undefined) return undefined;
return limit;
}
export async function getUserInfo(
userId: string,
): Promise<{ tier: SubscriptionTier; status: string; currentPeriodEnd?: Date }> {
@@ -223,7 +149,7 @@ export async function canUseFeature(
}
const tier = await getEffectiveTier(userId);
const limit = getLimit(tier, feature);
const limit = await getLimitAsync(tier, feature);
if (limit === undefined) {
return {
@@ -300,7 +226,7 @@ export async function reserveUsageOrThrow(
}
const tier = await getEffectiveTier(userId);
const limit = getLimit(tier, feature);
const limit = await getLimitAsync(tier, feature);
if (limit === undefined) {
throw new QuotaExceededError(
@@ -374,8 +300,8 @@ 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();
const features = Object.keys(TIER_LIMITS[tier]);
if (features.length === 0) return {};
@@ -387,7 +313,7 @@ export async function getUserQuotas(
const result: Record<string, { remaining: number; limit: number; used: number }> = {};
for (let i = 0; i < features.length; i++) {
const feature = features[i];
const limit = getLimit(tier, feature) ?? 0;
const limit = (await getLimitAsync(tier, feature)) ?? 0;
const current = parseRedisInt(values[i]);
result[feature] = {
remaining: limit === Infinity ? Infinity : Math.max(0, limit - current),
@@ -401,12 +327,17 @@ export async function getUserQuotas(
console.error('[entitlements] getUserQuotas Redis error:', err);
const result: Record<string, { remaining: number; limit: number; used: number }> = {};
for (const feature of features) {
const limit = getLimit(tier, feature) ?? 0;
const limit = (await getLimitAsync(tier, feature)) ?? 0;
result[feature] = { remaining: limit, limit, used: 0 };
}
return result;
}
}
export { TIER_LIMITS, getLimit };
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);
}