feat: publication IA (magazine/brief/essay) + fixes critique
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m22s
CI / Deploy production (on server) (push) Has been skipped

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:
Antigravity
2026-06-28 07:32:57 +00:00
parent 902fe95a69
commit 96e7902f01
169 changed files with 5382 additions and 1527 deletions

View File

@@ -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,