d2faf98ac1
Fixes the 13-item codebase health scan backlog: wraps meal-plan generation in a transaction, adds missing userId/GIN indexes, fixes an IPv6-parsing gap in the webhook SSRF guard (and an identical duplicated bug in the AI URL-import path, now consolidated onto one implementation), paginates the collections list, dedupes the AI recipe Zod schemas, wires up Stripe tier sync, rate-limits AI key rotation, gets `pnpm typecheck` actually working, and adds test coverage for the previously-untested admin/webhooks routes. Two flagged issues (collection removeRecipeId IDOR, tier-limit race) turned out to already be fixed/non-issues on inspection — noted in TODO.md rather than silently dropped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
53 lines
2.0 KiB
TypeScript
53 lines
2.0 KiB
TypeScript
import { generateObject } from "ai";
|
|
import { z } from "zod";
|
|
import { resolveModel, type AiConfig } from "../factory";
|
|
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
|
|
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
|
|
|
|
const ImportedRecipeSchema = z.object({
|
|
title: z.string(),
|
|
description: z.string().optional(),
|
|
baseServings: z.number().int().min(1).optional(),
|
|
prepMins: z.number().int().min(0).optional(),
|
|
cookMins: z.number().int().min(0).optional(),
|
|
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
|
dietaryTags: dietaryTagsSchema.optional(),
|
|
ingredients: z.array(ingredientSchema(z.string())),
|
|
steps: z.array(stepSchema),
|
|
});
|
|
|
|
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
|
|
|
|
export async function importFromUrl(url: string, config?: AiConfig): Promise<ImportedRecipe> {
|
|
const ssrfError = await validateWebhookUrl(url);
|
|
if (ssrfError) throw new Error(ssrfError);
|
|
|
|
const res = await fetch(url, {
|
|
headers: { "User-Agent": "Mozilla/5.0 (compatible; Epicure/1.0; recipe importer)" },
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
|
|
if (!res.ok) throw new Error(`Failed to fetch URL: ${res.status}`);
|
|
|
|
const html = await res.text();
|
|
// strip scripts/styles, take first 30k chars to stay within context
|
|
const cleaned = html
|
|
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
.replace(/<[^>]+>/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.slice(0, 30000);
|
|
|
|
const model = resolveModel(config);
|
|
|
|
const { object } = await generateObject({
|
|
model,
|
|
schema: ImportedRecipeSchema,
|
|
system:
|
|
"You are an expert at extracting structured recipe data from web page text. Extract all recipe information accurately. For ingredients, separate quantity, unit, and name. For steps, extract timer durations in seconds when mentioned.",
|
|
prompt: `Extract the recipe from this web page:\n\nURL: ${url}\n\nContent:\n${cleaned}`,
|
|
});
|
|
|
|
return object;
|
|
}
|