feat: publication IA (magazine/brief/essay) + fixes critique
Publication IA: - 4 templates (magazine, brief, essay, simple) avec CSS riche - Rewrite IA (article/exercises/tutorial/reference/mixed) - Modération avec timeout 12s + fallback safe - Quotas publish_enhance par tier (basic=2, pro=15, business=100) - Détection contenu stale (hash) - Migration DB publishedContent/publishedTemplate/publishedSourceHash Fixes: - cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast - _isShared ajouté au type Note (champ virtuel serveur) - callout colors PDF export: extraction fonction pure testable - admin/published: guard note.userId null - Cmd+S fonctionne en mode dialog (pas seulement fullPage) i18n: - 23 clés publish* traduites dans les 15 locales - Extension Web Clipper: 13 locales mise à jour Tests: - callout-colors.test.ts (6 tests) - note-visible-in-view.test.ts (5 tests) - entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs - 199/199 tests passent Tracker: user-stories.md sync avec sprint-status.yaml
This commit is contained in:
@@ -85,6 +85,95 @@ export class QuotaExceededError extends Error {
|
||||
|
||||
const TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||
|
||||
function getPeriodDates(): { period: string; periodStart: Date; periodEnd: Date } {
|
||||
const period = getCurrentPeriodKey();
|
||||
const periodStart = new Date(`${period}-01T00:00:00.000Z`);
|
||||
const periodEnd = new Date(Date.UTC(periodStart.getUTCFullYear(), periodStart.getUTCMonth() + 1, 1));
|
||||
return { period, periodStart, periodEnd };
|
||||
}
|
||||
|
||||
async function getDatabaseUsageCounts(
|
||||
userId: string,
|
||||
features: string[],
|
||||
): Promise<Record<string, number>> {
|
||||
if (features.length === 0) return {};
|
||||
const { periodStart } = getPeriodDates();
|
||||
const rows = await prisma.usageLog.findMany({
|
||||
where: { userId, periodStart, feature: { in: features } },
|
||||
select: { feature: true, requestsCount: true },
|
||||
});
|
||||
return Object.fromEntries(rows.map((r) => [r.feature, r.requestsCount]));
|
||||
}
|
||||
|
||||
async function mirrorUsageCountToDatabase(
|
||||
userId: string,
|
||||
feature: string,
|
||||
requestsCount: number,
|
||||
): Promise<void> {
|
||||
const { periodStart, periodEnd } = getPeriodDates();
|
||||
await prisma.usageLog.upsert({
|
||||
where: {
|
||||
userId_feature_periodStart: { userId, feature, periodStart },
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
feature,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
requestsCount,
|
||||
tokensUsed: 0,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
requestsCount,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error('[entitlements] Failed to mirror usage to DB:', err);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fallback when Redis is unavailable — atomic increment in PostgreSQL. */
|
||||
async function reserveUsageInDatabase(
|
||||
userId: string,
|
||||
feature: string,
|
||||
limit: number,
|
||||
): Promise<number> {
|
||||
const { periodStart, periodEnd } = getPeriodDates();
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.usageLog.findUnique({
|
||||
where: { userId_feature_periodStart: { userId, feature, periodStart } },
|
||||
});
|
||||
const current = existing?.requestsCount ?? 0;
|
||||
if (current >= limit) return -1;
|
||||
|
||||
const updated = await tx.usageLog.upsert({
|
||||
where: { userId_feature_periodStart: { userId, feature, periodStart } },
|
||||
create: {
|
||||
userId,
|
||||
feature,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
requestsCount: 1,
|
||||
tokensUsed: 0,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
requestsCount: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return updated.requestsCount;
|
||||
});
|
||||
}
|
||||
|
||||
function parseReserveResult(raw: unknown): number {
|
||||
const n = typeof raw === 'number' ? raw : Number(raw);
|
||||
return Number.isFinite(n) ? n : NaN;
|
||||
}
|
||||
|
||||
function shouldFailClosedOnRedisError(): boolean {
|
||||
return process.env.NODE_ENV === 'production';
|
||||
}
|
||||
@@ -182,7 +271,9 @@ export async function canUseFeature(
|
||||
try {
|
||||
const key = getRedisKey(userId, feature);
|
||||
const currentStr = await redis.get(key);
|
||||
const current = parseRedisInt(currentStr);
|
||||
const redisCurrent = parseRedisInt(currentStr);
|
||||
const dbCounts = await getDatabaseUsageCounts(userId, [feature]);
|
||||
const current = Math.max(redisCurrent, dbCounts[feature] ?? 0);
|
||||
const allowed = current < limit;
|
||||
|
||||
if (!allowed) {
|
||||
@@ -265,13 +356,14 @@ export async function reserveUsageOrThrow(
|
||||
|
||||
try {
|
||||
const key = getRedisKey(userId, feature);
|
||||
const newCount = await redis.eval(
|
||||
const raw = await redis.eval(
|
||||
RESERVE_LUA,
|
||||
1,
|
||||
key,
|
||||
String(limit),
|
||||
String(TTL_SECONDS),
|
||||
) as number;
|
||||
);
|
||||
const newCount = parseReserveResult(raw);
|
||||
|
||||
if (newCount === -1) {
|
||||
const byokConfigured = await hasAnyActiveByok(userId);
|
||||
@@ -284,9 +376,36 @@ export async function reserveUsageOrThrow(
|
||||
{ currentTier: tier },
|
||||
);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(newCount) || newCount < 1) {
|
||||
throw new Error(`Invalid Redis reserve result: ${String(raw)}`);
|
||||
}
|
||||
|
||||
await mirrorUsageCountToDatabase(userId, feature, newCount);
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) throw err;
|
||||
console.error('[entitlements] Redis unavailable:', err);
|
||||
|
||||
console.error('[entitlements] Redis reserve failed, trying DB fallback:', err);
|
||||
|
||||
try {
|
||||
const dbCount = await reserveUsageInDatabase(userId, feature, limit);
|
||||
if (dbCount === -1) {
|
||||
const byokConfigured = await hasAnyActiveByok(userId);
|
||||
throw new QuotaExceededError(
|
||||
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
||||
feature,
|
||||
limit,
|
||||
limit,
|
||||
byokConfigured,
|
||||
{ currentTier: tier },
|
||||
);
|
||||
}
|
||||
return;
|
||||
} catch (dbErr) {
|
||||
if (dbErr instanceof QuotaExceededError) throw dbErr;
|
||||
console.error('[entitlements] DB reserve fallback failed:', dbErr);
|
||||
}
|
||||
|
||||
if (shouldFailClosedOnRedisError()) {
|
||||
throw new QuotaServiceUnavailableError();
|
||||
}
|
||||
@@ -334,12 +453,15 @@ export async function getUserQuotas(
|
||||
|
||||
try {
|
||||
const values = await redis.mget(...keys);
|
||||
const dbCounts = await getDatabaseUsageCounts(userId, features);
|
||||
|
||||
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]);
|
||||
const redisCurrent = parseRedisInt(values[i]);
|
||||
const dbCurrent = dbCounts[feature] ?? 0;
|
||||
const current = Math.max(redisCurrent, dbCurrent);
|
||||
result[feature] = {
|
||||
remaining: limit === Infinity ? Infinity : Math.max(0, limit - current),
|
||||
limit,
|
||||
|
||||
Reference in New Issue
Block a user