Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 7 additions & 43 deletions app/api/entries/[id]/diff/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,7 @@ import { NextRequest } from 'next/server';

import { badRequest } from '@/lib/api/errors';
import { handleApiError, ok } from '@/lib/api/response';
import { highlightUnknownWords } from '@/lib/cefr/vocab';
import { computeReadOnlyDiff } from '@/lib/diff/read-only';
import { buildLearnerContextFromTexts } from '@/lib/learning/context';
import {
buildLearningHighlightsWithAI,
normalizeLearningHighlights,
} from '@/lib/learning/highlight';
import { authedClient } from '@/lib/supabase/authed';

export async function GET(
Expand All @@ -18,54 +12,24 @@ export async function GET(
try {
const { id } = await context.params;
const { user, client } = await authedClient(req);
const refresh = req.nextUrl.searchParams.get("refresh") === "1";

const [{ data: entry, error: entryError }, { data: profile, error: profileError }, { data: learnerPhotos, error: learnerPhotosError }] =
await Promise.all([
client.from('entries').select('*').eq('id', id).single(),
client.from('user_profiles').select('cefr_level').eq('id', user.id).single(),
client
.from('entry_photos')
.select('draft_fr,final_fr')
.eq('user_id', user.id)
.order('updated_at', { ascending: false })
.limit(30)
]);
const { data: entry, error: entryError } = await client
.from('entries')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single();

if (entryError || !entry) {
badRequest('ENTRY_NOT_FOUND', 'Entry not found');
}
if (profileError || !profile) {
badRequest('PROFILE_NOT_FOUND', 'Profile not found');
}
if (learnerPhotosError) {
badRequest('ENTRY_PHOTOS_LIST_FAILED', 'Unable to fetch learner context');
}
if (!entry.final_fr) {
badRequest('FINAL_TEXT_REQUIRED', 'Final French text not generated yet');
}

const diff = computeReadOnlyDiff(entry.draft_fr, entry.final_fr);
const learnerContext = buildLearnerContextFromTexts([
...(learnerPhotos ?? []).flatMap((row) => [row.draft_fr ?? '', row.final_fr ?? '']),
entry.draft_fr ?? '',
entry.final_fr ?? '',
]);
const learningHighlights =
(!refresh ? normalizeLearningHighlights(entry.learning_highlights) : null) ??
await buildLearningHighlightsWithAI(
entry.draft_fr ?? '',
entry.final_fr ?? '',
profile.cefr_level,
learnerContext,
);

return ok({
entry_id: entry.id,
diff,
learning_highlights: learningHighlights,
draft_highlights: highlightUnknownWords(entry.draft_fr, profile.cefr_level),
final_highlights: highlightUnknownWords(entry.final_fr, profile.cefr_level)
diff
});
} catch (error) {
return handleApiError(error);
Expand Down
32 changes: 32 additions & 0 deletions app/api/entries/[id]/export/pdf/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NextRequest } from 'next/server';

import { parseJson } from '@/lib/api/parse';
import { handleApiError, ok } from '@/lib/api/response';
import { exportSchema } from '@/lib/api/schemas';
import { assertRateLimit } from '@/lib/rate-limit/memory';
import { authedClient } from '@/lib/supabase/authed';
import { runExportWorkflow } from '@/lib/workflows/export';

export async function POST(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const { user, client } = await authedClient(req);
assertRateLimit(user.id, 'pdf_export', 12, 60_000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Share one rate-limit bucket across export formats.

The PPTX route uses the key pptx_export with the same budget. Because the keys differ, one user can start 24 exports per minute across the two routes. Each export renders a full presentation and converts every photo through sharp on the request thread. Use one export key so the budget covers total export cost.

🛡️ Proposed change
-    assertRateLimit(user.id, 'pdf_export', 12, 60_000);
+    assertRateLimit(user.id, 'export', 12, 60_000);

Apply the same key in app/api/entries/[id]/export/pptx/route.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/entries/`[id]/export/pdf/route.ts at line 17, Use the shared `export`
rate-limit key in both the PDF route’s `assertRateLimit` call and the PPTX
route’s corresponding call, preserving the existing budget and interval so the
limit applies across all export formats.

const payload = await parseJson(req, exportSchema);

const result = await runExportWorkflow({
client,
userId: user.id,
entryId: id,
includeMemos: Boolean(payload.include_memos),
format: 'pdf'
});

return ok(result);
} catch (error) {
return handleApiError(error);
}
}
3 changes: 2 additions & 1 deletion app/api/entries/[id]/export/pptx/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export async function POST(
client,
userId: user.id,
entryId: id,
includeMemos: Boolean(payload.include_memos)
includeMemos: Boolean(payload.include_memos),
format: 'pptx'
});

return ok(result);
Expand Down
83 changes: 6 additions & 77 deletions app/api/entries/[id]/memos/auto/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,6 @@ import { NextRequest } from 'next/server';
import { generateLearningNotes } from '@/lib/ai/client';
import { badRequest } from '@/lib/api/errors';
import { handleApiError, ok } from '@/lib/api/response';
import { buildLearnerContextFromTexts } from '@/lib/learning/context';
import {
buildLearningHighlightsFromDiff,
buildLearningHighlightsWithAI,
normalizeLearningHighlights,
} from '@/lib/learning/highlight';
import { assertRateLimit } from '@/lib/rate-limit/memory';
import { authedClient } from '@/lib/supabase/authed';

Expand All @@ -24,21 +18,14 @@ export async function GET(
const [
{ data: entry, error: entryError },
{ data: photos, error: photosError },
{ data: learnerPhotos, error: learnerPhotosError },
{ data: profile, error: profileError }
] = await Promise.all([
client.from('entries').select('*').eq('id', entryId).single(),
client
.from('entry_photos')
.select('draft_fr,final_fr,learning_highlights')
.select('draft_fr,final_fr')
.eq('entry_id', entryId)
.order('position', { ascending: true }),
client
.from('entry_photos')
.select('draft_fr,final_fr')
.eq('user_id', user.id)
.order('updated_at', { ascending: false })
.limit(30),
client
.from('user_profiles')
.select('cefr_level,grammatical_gender,politeness_pref,service_language')
Expand All @@ -52,86 +39,29 @@ export async function GET(
if (photosError) {
badRequest('ENTRY_PHOTOS_LIST_FAILED', 'Unable to fetch entry photos');
}
if (learnerPhotosError) {
badRequest('ENTRY_PHOTOS_LIST_FAILED', 'Unable to fetch learner context');
}
if (profileError || !profile) {
badRequest('PROFILE_NOT_FOUND', 'Profile not found');
}

const learnerContext = buildLearnerContextFromTexts([
...(learnerPhotos ?? []).flatMap((row) => [row.draft_fr ?? '', row.final_fr ?? '']),
entry.draft_fr ?? '',
entry.final_fr ?? '',
]);

const pairs =
photos && photos.length
? await Promise.all(photos
? photos
.filter((p) => (p.final_fr ?? '').trim())
.map(async (p) => {
const baseHighlights =
normalizeLearningHighlights(p.learning_highlights) ??
await buildLearningHighlightsWithAI(
p.draft_fr ?? '',
p.final_fr ?? '',
profile.cefr_level,
learnerContext,
);

const highlights = buildLearningHighlightsFromDiff(
p.draft_fr ?? '',
p.final_fr ?? '',
baseHighlights,
);

return {
.map((p) => ({
draftFr: p.draft_fr ?? '',
finalFr: p.final_fr ?? '',
highlights: {
grammarWords: highlights.grammarWords,
knownWords: highlights.knownWords,
unknownWords: highlights.unknownWords,
},
};
}))
: entry.final_fr
? [await (async () => {
const baseHighlights =
normalizeLearningHighlights(entry.learning_highlights) ??
await buildLearningHighlightsWithAI(
entry.draft_fr ?? '',
entry.final_fr ?? '',
profile.cefr_level,
learnerContext,
);

const highlights = buildLearningHighlightsFromDiff(
entry.draft_fr ?? '',
entry.final_fr ?? '',
baseHighlights,
);

return {
? [{
draftFr: entry.draft_fr ?? '',
finalFr: entry.final_fr ?? '',
highlights: {
grammarWords: highlights.grammarWords,
knownWords: highlights.knownWords,
unknownWords: highlights.unknownWords,
},
};
})()]
}]
: [];

if (!pairs.length) {
return ok({ suggestions: [] });
}

const unknownWords = [...new Set(
pairs.flatMap((pair) => pair.highlights?.unknownWords ?? [])
)];

const suggestions = await generateLearningNotes(
pairs,
{
Expand All @@ -140,8 +70,7 @@ export async function GET(
politenessPref: profile.politeness_pref
},
{
language: profile.service_language === 'fr' ? 'fr' : 'ja',
unknownWords
language: profile.service_language === 'fr' ? 'fr' : 'ja'
}
);

Expand Down
68 changes: 7 additions & 61 deletions app/api/entries/[id]/photos/[photoId]/diff/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,7 @@ import { NextRequest } from "next/server";

import { badRequest } from "@/lib/api/errors";
import { handleApiError, ok } from "@/lib/api/response";
import { highlightUnknownWords } from "@/lib/cefr/vocab";
import { computeReadOnlyDiff } from "@/lib/diff/read-only";
import { buildLearnerContextFromTexts } from "@/lib/learning/context";
import {
buildLearningHighlightsWithAI,
normalizeLearningHighlights,
} from "@/lib/learning/highlight";
import { authedClient } from "@/lib/supabase/authed";

export async function GET(
Expand All @@ -18,76 +12,28 @@ export async function GET(
try {
const { id: entryId, photoId } = await context.params;
const { user, client } = await authedClient(req);
const refresh = req.nextUrl.searchParams.get("refresh") === "1";

const [
{ data: photo, error: photoError },
{ data: profile, error: profileError },
{ data: learnerPhotos, error: learnerPhotosError },
] = await Promise.all([
client
.from("entry_photos")
.select("id,entry_id,user_id,draft_fr,final_fr,learning_highlights")
.eq("id", photoId)
.eq("entry_id", entryId)
.single(),
client
.from("user_profiles")
.select("cefr_level")
.eq("id", user.id)
.single(),
client
.from("entry_photos")
.select("draft_fr,final_fr")
.eq("user_id", user.id)
.order("updated_at", { ascending: false })
.limit(30),
]);
const { data: photo, error: photoError } = await client
.from("entry_photos")
.select("id,entry_id,user_id,draft_fr,final_fr")
.eq("id", photoId)
.eq("entry_id", entryId)
.single();

if (photoError || !photo) {
badRequest("ENTRY_PHOTO_NOT_FOUND", "Entry photo not found");
}
if (photo.user_id !== user.id) {
badRequest("ENTRY_PHOTO_NOT_FOUND", "Entry photo not found");
}
if (profileError || !profile) {
badRequest("PROFILE_NOT_FOUND", "Profile not found");
}
if (learnerPhotosError) {
badRequest("ENTRY_PHOTOS_LIST_FAILED", "Unable to fetch learner context");
}
if (!photo.final_fr) {
badRequest("FINAL_TEXT_REQUIRED", "Final French text not generated yet");
}

const diff = computeReadOnlyDiff(photo.draft_fr, photo.final_fr);
const learnerContext = buildLearnerContextFromTexts([
...(learnerPhotos ?? []).flatMap((row) => [row.draft_fr ?? "", row.final_fr ?? ""]),
photo.draft_fr ?? "",
photo.final_fr ?? "",
]);
const learningHighlights =
(!refresh ? normalizeLearningHighlights(photo.learning_highlights) : null) ??
await buildLearningHighlightsWithAI(
photo.draft_fr ?? "",
photo.final_fr ?? "",
profile.cefr_level,
learnerContext,
);

return ok({
entry_id: entryId,
photo_id: photoId,
diff,
learning_highlights: learningHighlights,
draft_highlights: highlightUnknownWords(
photo.draft_fr,
profile.cefr_level,
),
final_highlights: highlightUnknownWords(
photo.final_fr,
profile.cefr_level,
),
diff
});
} catch (error) {
return handleApiError(error);
Expand Down
10 changes: 6 additions & 4 deletions app/api/exports/[token]/download/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';

import { badRequest } from '@/lib/api/errors';
import { buildPptxContentDisposition } from '@/lib/pptx/download';
import { handleApiError } from '@/lib/api/response';
import { buildExportContentDisposition, ExportFormat } from '@/lib/exports/download';
import { hashExportToken } from '@/lib/exports/token';
import { exportBucket } from '@/lib/storage/buckets';
import { createServiceClient } from '@/lib/supabase/client';
Expand Down Expand Up @@ -44,12 +44,14 @@ export async function GET(
: { data: null };

const arrayBuffer = await download.data.arrayBuffer();
const format: ExportFormat = file.object_path.toLowerCase().endsWith('.pdf') ? 'pdf' : 'pptx';
return new NextResponse(Buffer.from(arrayBuffer), {
status: 200,
headers: {
'Content-Type':
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'Content-Disposition': buildPptxContentDisposition(entry?.title_fr)
'Content-Type': format === 'pdf'
? 'application/pdf'
: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'Content-Disposition': buildExportContentDisposition(entry?.title_fr, format)
}
});
} catch (error) {
Expand Down
Loading