fix: 5 bugs critiques de l'éditeur (Phase 1 audit)
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:
@@ -5,8 +5,6 @@ import { z } from 'zod'
|
||||
import { toolRegistry } from './registry'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { buildPresentationHTML } from './slides-html-builder'
|
||||
import { incrementUsageAsync } from '@/lib/entitlements'
|
||||
|
||||
const slideSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('title'), title: z.string(), subtitle: z.string().optional() }),
|
||||
z.object({ type: z.literal('bullets'), title: z.string(), items: z.array(z.string()) }),
|
||||
@@ -86,9 +84,6 @@ RULES:
|
||||
|
||||
console.log('[Slides Tool] Canvas created:', canvas.id, '| Slides:', cappedSlides.length, '| Size:', Math.round(html.length / 1024), 'KB')
|
||||
|
||||
// Decrement slide_generate quota after successful canvas creation
|
||||
incrementUsageAsync(ctx.userId, 'slide_generate')
|
||||
|
||||
if (ctx.actionId) {
|
||||
await prisma.agentAction.update({
|
||||
where: { id: ctx.actionId },
|
||||
|
||||
@@ -10,6 +10,9 @@ export type AuditAction =
|
||||
| 'PASSWORD_RESET'
|
||||
| 'AI_CONSENT_GRANTED'
|
||||
| 'AI_CONSENT_REVOKED'
|
||||
| 'SUBSCRIPTION_OVERRIDE'
|
||||
| 'BILLING_CONFIG_UPDATED'
|
||||
| 'PLAN_ENTITLEMENT_UPDATED'
|
||||
|
||||
export interface AuditLogParams {
|
||||
userId?: string | null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SubscriptionTier } from '@/lib/entitlements';
|
||||
import type { SubscriptionTier } from '@/lib/plan-entitlements';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { getConfigValue } from '@/lib/config';
|
||||
|
||||
export type BillingTier = 'PRO' | 'BUSINESS';
|
||||
export type BillingInterval = 'month' | 'year';
|
||||
@@ -21,6 +22,14 @@ export const DEFAULT_PRICES: Record<BillingTier, Record<BillingInterval, Dynamic
|
||||
},
|
||||
};
|
||||
|
||||
export async function isBillingEnabled(): Promise<boolean> {
|
||||
const flag = await getConfigValue('BILLING_ENABLED', '');
|
||||
if (flag === 'true') return true;
|
||||
if (flag === 'false') return false;
|
||||
return process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true'
|
||||
|| process.env.NODE_ENV === 'development';
|
||||
}
|
||||
|
||||
export async function getDynamicPrices(): Promise<Record<BillingTier, Record<BillingInterval, DynamicPrice>>> {
|
||||
const isMock = !process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder';
|
||||
if (isMock) {
|
||||
@@ -40,13 +49,12 @@ export async function getDynamicPrices(): Promise<Record<BillingTier, Record<Bil
|
||||
|
||||
const retrieveAndFormatPrice = async (tier: BillingTier, interval: BillingInterval) => {
|
||||
try {
|
||||
const priceId = resolvePriceId(tier, interval);
|
||||
const priceId = await resolvePriceId(tier, interval);
|
||||
const price = await stripe.prices.retrieve(priceId);
|
||||
if (price.unit_amount !== null && price.unit_amount !== undefined) {
|
||||
const amount = price.unit_amount / 100;
|
||||
const currency = price.currency.toUpperCase();
|
||||
|
||||
// Format the price nicely
|
||||
|
||||
let display = '';
|
||||
if (currency === 'EUR') {
|
||||
display = `${amount.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} €`;
|
||||
@@ -57,12 +65,11 @@ export async function getDynamicPrices(): Promise<Record<BillingTier, Record<Bil
|
||||
} else {
|
||||
display = `${amount} ${currency}`;
|
||||
}
|
||||
|
||||
|
||||
result[tier][interval] = { display, amount, currency };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[stripe-prices] Failed to retrieve price for ${tier}/${interval}:`, err);
|
||||
// Fallback to default in case of error
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,40 +83,42 @@ export async function getDynamicPrices(): Promise<Record<BillingTier, Record<Bil
|
||||
return result;
|
||||
}
|
||||
|
||||
export function resolvePriceId(tier: BillingTier, interval: BillingInterval): string {
|
||||
const map: Record<BillingTier, Record<BillingInterval, string>> = {
|
||||
PRO: {
|
||||
month: process.env.STRIPE_PRICE_PRO_MONTHLY!,
|
||||
year: process.env.STRIPE_PRICE_PRO_ANNUAL!,
|
||||
},
|
||||
BUSINESS: {
|
||||
month: process.env.STRIPE_PRICE_BUSINESS_MONTHLY!,
|
||||
year: process.env.STRIPE_PRICE_BUSINESS_ANNUAL!,
|
||||
},
|
||||
};
|
||||
const priceId = map[tier][interval];
|
||||
if (!priceId) {
|
||||
const isMock = !process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder';
|
||||
if (isMock && process.env.NODE_ENV !== 'test') {
|
||||
return `price_mock_${tier.toLowerCase()}_${interval}`;
|
||||
}
|
||||
throw new Error(`No Stripe price ID configured for ${tier}/${interval}`);
|
||||
const PRICE_ENV_KEYS: Record<BillingTier, Record<BillingInterval, string>> = {
|
||||
PRO: {
|
||||
month: 'STRIPE_PRICE_PRO_MONTHLY',
|
||||
year: 'STRIPE_PRICE_PRO_ANNUAL',
|
||||
},
|
||||
BUSINESS: {
|
||||
month: 'STRIPE_PRICE_BUSINESS_MONTHLY',
|
||||
year: 'STRIPE_PRICE_BUSINESS_ANNUAL',
|
||||
},
|
||||
};
|
||||
|
||||
export async function resolvePriceId(tier: BillingTier, interval: BillingInterval): Promise<string> {
|
||||
const configKey = PRICE_ENV_KEYS[tier][interval];
|
||||
const fromDb = await getConfigValue(configKey, '');
|
||||
const priceId = fromDb || process.env[configKey] || '';
|
||||
if (priceId) return priceId;
|
||||
|
||||
const isMock = !process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder';
|
||||
if (isMock && process.env.NODE_ENV !== 'test') {
|
||||
return `price_mock_${tier.toLowerCase()}_${interval}`;
|
||||
}
|
||||
return priceId;
|
||||
throw new Error(`No Stripe price ID configured for ${tier}/${interval}`);
|
||||
}
|
||||
|
||||
export function priceIdToTier(priceId: string): SubscriptionTier | null {
|
||||
export async function priceIdToTier(priceId: string): Promise<SubscriptionTier | null> {
|
||||
if (priceId && priceId.startsWith('price_mock_')) {
|
||||
if (priceId.includes('pro')) return 'PRO';
|
||||
if (priceId.includes('business')) return 'BUSINESS';
|
||||
return 'BASIC';
|
||||
}
|
||||
|
||||
const entries: Array<[string | undefined, SubscriptionTier]> = [
|
||||
[process.env.STRIPE_PRICE_PRO_MONTHLY, 'PRO'],
|
||||
[process.env.STRIPE_PRICE_PRO_ANNUAL, 'PRO'],
|
||||
[process.env.STRIPE_PRICE_BUSINESS_MONTHLY, 'BUSINESS'],
|
||||
[process.env.STRIPE_PRICE_BUSINESS_ANNUAL, 'BUSINESS'],
|
||||
const entries: Array<[string, SubscriptionTier]> = [
|
||||
[(await getConfigValue('STRIPE_PRICE_PRO_MONTHLY', '')) || process.env.STRIPE_PRICE_PRO_MONTHLY || '', 'PRO'],
|
||||
[(await getConfigValue('STRIPE_PRICE_PRO_ANNUAL', '')) || process.env.STRIPE_PRICE_PRO_ANNUAL || '', 'PRO'],
|
||||
[(await getConfigValue('STRIPE_PRICE_BUSINESS_MONTHLY', '')) || process.env.STRIPE_PRICE_BUSINESS_MONTHLY || '', 'BUSINESS'],
|
||||
[(await getConfigValue('STRIPE_PRICE_BUSINESS_ANNUAL', '')) || process.env.STRIPE_PRICE_BUSINESS_ANNUAL || '', 'BUSINESS'],
|
||||
];
|
||||
for (const [envPriceId, tier] of entries) {
|
||||
if (envPriceId && envPriceId === priceId) return tier;
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function syncSubscriptionFromStripe(
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const priceId = subscription.items.data[0]?.price?.id ?? null;
|
||||
const resolvedTier = priceId ? priceIdToTier(priceId) : null;
|
||||
const resolvedTier = priceId ? await priceIdToTier(priceId) : null;
|
||||
|
||||
const tierFromMetadata =
|
||||
(subscription.metadata?.tier as string | undefined) ??
|
||||
|
||||
@@ -56,6 +56,11 @@ const ENV_FALLBACKS: Record<string, string> = {
|
||||
// Auth / misc
|
||||
ALLOW_REGISTRATION: process.env.ALLOW_REGISTRATION || '',
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL || '',
|
||||
BILLING_ENABLED: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED || process.env.BILLING_ENABLED || '',
|
||||
STRIPE_PRICE_PRO_MONTHLY: process.env.STRIPE_PRICE_PRO_MONTHLY || '',
|
||||
STRIPE_PRICE_PRO_ANNUAL: process.env.STRIPE_PRICE_PRO_ANNUAL || '',
|
||||
STRIPE_PRICE_BUSINESS_MONTHLY: process.env.STRIPE_PRICE_BUSINESS_MONTHLY || '',
|
||||
STRIPE_PRICE_BUSINESS_ANNUAL: process.env.STRIPE_PRICE_BUSINESS_ANNUAL || '',
|
||||
}
|
||||
|
||||
export async function getSystemConfig() {
|
||||
|
||||
@@ -121,7 +121,7 @@ interface BlockPlaceholder {
|
||||
function preprocessCustomNodes(html: string): { html: string; placeholders: BlockPlaceholder[] } {
|
||||
const placeholders: BlockPlaceholder[] = []
|
||||
|
||||
// liveBlock: <div data-live-block="true" sourceNoteId="..." blockId="..."></div>
|
||||
// liveBlock
|
||||
let result = html.replace(
|
||||
/<div([^>]*?data-live-block[^>]*?)>\s*<\/div>/gi,
|
||||
(_match, attrs) => {
|
||||
@@ -133,7 +133,7 @@ function preprocessCustomNodes(html: string): { html: string; placeholders: Bloc
|
||||
}
|
||||
)
|
||||
|
||||
// structuredViewBlock: <div data-structured-view-block="true" ...></div>
|
||||
// structuredViewBlock
|
||||
result = result.replace(
|
||||
/<div([^>]*?data-structured-view-block[^>]*?)>\s*<\/div>/gi,
|
||||
(_match, attrs) => {
|
||||
@@ -149,6 +149,71 @@ function preprocessCustomNodes(html: string): { html: string; placeholders: Bloc
|
||||
}
|
||||
)
|
||||
|
||||
// toggleBlock — preserve as HTML comment
|
||||
result = result.replace(
|
||||
/<div([^>]*?data-type="toggle-block"[^>]*?)>([\s\S]*?)<\/div>/gi,
|
||||
(_match, attrs, content) => {
|
||||
const opened = attrs.match(/data-opened="([^"]*)"/i)?.[1] || 'true'
|
||||
const key = `${SENTINEL_PREFIX}TOGGLE${placeholders.length}`
|
||||
placeholders.push({ key, comment: `<!-- toggle: opened=${opened} -->${content}\n<!-- /toggle -->` })
|
||||
return `<p>${key}</p>`
|
||||
}
|
||||
)
|
||||
|
||||
// calloutBlock
|
||||
result = result.replace(
|
||||
/<div([^>]*?data-type="callout-block"[^>]*?)>([\s\S]*?)<\/div>/gi,
|
||||
(_match, attrs, content) => {
|
||||
const type = attrs.match(/data-callout-type="([^"]*)"/i)?.[1] || 'info'
|
||||
const key = `${SENTINEL_PREFIX}CALLOUT${placeholders.length}`
|
||||
placeholders.push({ key, comment: `<!-- callout: type=${type} -->${content}\n<!-- /callout -->` })
|
||||
return `<p>${key}</p>`
|
||||
}
|
||||
)
|
||||
|
||||
// mathEquationBlock
|
||||
result = result.replace(
|
||||
/<div([^>]*?data-type="math-equation"[^>]*?)>([\s\S]*?)<\/div>/gi,
|
||||
(_match, attrs, content) => {
|
||||
const latex = attrs.match(/data-latex="([^"]*)"/i)?.[1] || content.trim()
|
||||
const key = `${SENTINEL_PREFIX}MATH${placeholders.length}`
|
||||
placeholders.push({ key, comment: `<!-- math: ${latex} -->` })
|
||||
return `<p>${key}</p>`
|
||||
}
|
||||
)
|
||||
|
||||
// outlineBlock
|
||||
result = result.replace(
|
||||
/<div[^>]*data-type="outline-block"[^>]*><\/div>/gi,
|
||||
() => {
|
||||
const key = `${SENTINEL_PREFIX}OUTLINE${placeholders.length}`
|
||||
placeholders.push({ key, comment: `<!-- outline -->` })
|
||||
return `<p>${key}</p>`
|
||||
}
|
||||
)
|
||||
|
||||
// columns
|
||||
result = result.replace(
|
||||
/<div([^>]*?data-type="columns"[^>]*?)>([\s\S]*?)<\/div>/gi,
|
||||
(_match, attrs, content) => {
|
||||
const cols = attrs.match(/cols="([^"]*)"/i)?.[1] || '2'
|
||||
const key = `${SENTINEL_PREFIX}COLUMNS${placeholders.length}`
|
||||
placeholders.push({ key, comment: `<!-- columns: cols=${cols} -->${content}\n<!-- /columns -->` })
|
||||
return `<p>${key}</p>`
|
||||
}
|
||||
)
|
||||
|
||||
// linkPreviewBlock
|
||||
result = result.replace(
|
||||
/<div([^>]*?data-type="link-preview-block"[^>]*?)>[\s\S]*?<\/div>/gi,
|
||||
(_match, attrs) => {
|
||||
const url = attrs.match(/data-url="([^"]*)"/i)?.[1] || ''
|
||||
const key = `${SENTINEL_PREFIX}LINKPREVIEW${placeholders.length}`
|
||||
placeholders.push({ key, comment: `<!-- link-preview: ${url} -->` })
|
||||
return `<p>${key}</p>`
|
||||
}
|
||||
)
|
||||
|
||||
return { html: result, placeholders }
|
||||
}
|
||||
|
||||
@@ -188,10 +253,10 @@ export function tiptapHTMLToMarkdown(html: string): string {
|
||||
export function markdownToHTML(markdown: string): string {
|
||||
if (!markdown || markdown.trim() === '') return ''
|
||||
|
||||
// marked v18+ uses synchronous parse by default when no async tokens
|
||||
// breaks: true — single \n becomes <br>, matching WYSIWYG expectations
|
||||
const html = marked.parse(markdown, {
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
breaks: true,
|
||||
}) as string
|
||||
|
||||
return html
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
199
memento-note/lib/plan-entitlements.ts
Normal file
199
memento-note/lib/plan-entitlements.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { prisma } from './prisma';
|
||||
import { VALID_FEATURES } from './quota-utils';
|
||||
|
||||
export type SubscriptionTier = 'BASIC' | 'PRO' | 'BUSINESS' | 'ENTERPRISE';
|
||||
|
||||
/** Hardcoded defaults — used when DB is empty or unavailable. */
|
||||
export const FALLBACK_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 CACHE_TTL_MS = 60_000;
|
||||
|
||||
type LimitMap = Record<SubscriptionTier, Record<string, number | undefined>>;
|
||||
|
||||
let cachedLimits: LimitMap | null = null;
|
||||
let cacheExpiresAt = 0;
|
||||
|
||||
function fallbackToLimitMap(): LimitMap {
|
||||
const map = {} as LimitMap;
|
||||
for (const tier of Object.keys(FALLBACK_TIER_LIMITS) as SubscriptionTier[]) {
|
||||
map[tier] = {};
|
||||
for (const [feature, limit] of Object.entries(FALLBACK_TIER_LIMITS[tier])) {
|
||||
map[tier][feature] = limit === 'unlimited' ? Infinity : limit;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildLimitMapFromRows(
|
||||
rows: Array<{ tier: SubscriptionTier; feature: string; limitValue: number | null }>,
|
||||
): LimitMap {
|
||||
const map = {} as LimitMap;
|
||||
for (const tier of ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE'] as SubscriptionTier[]) {
|
||||
map[tier] = {};
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
map[row.tier][row.feature] =
|
||||
row.limitValue === null ? Infinity : row.limitValue;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export function invalidateEntitlementCache(): void {
|
||||
cachedLimits = null;
|
||||
cacheExpiresAt = 0;
|
||||
}
|
||||
|
||||
async function loadLimitMap(): Promise<LimitMap> {
|
||||
const now = Date.now();
|
||||
if (cachedLimits && now < cacheExpiresAt) {
|
||||
return cachedLimits;
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await prisma.planEntitlement.findMany({
|
||||
select: { tier: true, feature: true, limitValue: true },
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
cachedLimits = fallbackToLimitMap();
|
||||
} else {
|
||||
cachedLimits = buildLimitMapFromRows(rows as Array<{
|
||||
tier: SubscriptionTier;
|
||||
feature: string;
|
||||
limitValue: number | null;
|
||||
}>);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[plan-entitlements] DB load failed, using fallback:', err);
|
||||
cachedLimits = fallbackToLimitMap();
|
||||
}
|
||||
|
||||
cacheExpiresAt = now + CACHE_TTL_MS;
|
||||
return cachedLimits;
|
||||
}
|
||||
|
||||
export async function getLimitAsync(
|
||||
tier: SubscriptionTier,
|
||||
feature: string,
|
||||
): Promise<number | undefined> {
|
||||
const map = await loadLimitMap();
|
||||
const limit = map[tier]?.[feature];
|
||||
if (limit === undefined) return undefined;
|
||||
if (limit === Infinity) return Infinity;
|
||||
return limit;
|
||||
}
|
||||
|
||||
export async function getTierFeaturesAsync(
|
||||
tier: SubscriptionTier,
|
||||
): Promise<string[]> {
|
||||
const map = await loadLimitMap();
|
||||
const fromDb = Object.keys(map[tier] ?? {});
|
||||
if (fromDb.length > 0) return fromDb;
|
||||
return Object.keys(FALLBACK_TIER_LIMITS[tier]);
|
||||
}
|
||||
|
||||
export async function getAllEntitlementsForAdmin(): Promise<
|
||||
Array<{ tier: SubscriptionTier; feature: string; limitValue: number | null; mode: 'limited' | 'unlimited' | 'unavailable' }>
|
||||
> {
|
||||
const rows = await prisma.planEntitlement.findMany({
|
||||
orderBy: [{ tier: 'asc' }, { feature: 'asc' }],
|
||||
});
|
||||
|
||||
if (rows.length > 0) {
|
||||
return rows.map((r) => ({
|
||||
tier: r.tier as SubscriptionTier,
|
||||
feature: r.feature,
|
||||
limitValue: r.limitValue,
|
||||
mode:
|
||||
r.limitValue === null
|
||||
? ('unlimited' as const)
|
||||
: ('limited' as const),
|
||||
}));
|
||||
}
|
||||
|
||||
const seeded: Array<{
|
||||
tier: SubscriptionTier;
|
||||
feature: string;
|
||||
limitValue: number | null;
|
||||
mode: 'limited' | 'unlimited' | 'unavailable';
|
||||
}> = [];
|
||||
|
||||
for (const tier of Object.keys(FALLBACK_TIER_LIMITS) as SubscriptionTier[]) {
|
||||
for (const feature of VALID_FEATURES) {
|
||||
const raw = FALLBACK_TIER_LIMITS[tier][feature];
|
||||
if (raw === undefined) continue;
|
||||
seeded.push({
|
||||
tier,
|
||||
feature,
|
||||
limitValue: raw === 'unlimited' ? null : raw,
|
||||
mode: raw === 'unlimited' ? 'unlimited' : 'limited',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return seeded;
|
||||
}
|
||||
|
||||
export { FALLBACK_TIER_LIMITS as TIER_LIMITS };
|
||||
Reference in New Issue
Block a user