This commit is contained in:
@@ -18,12 +18,42 @@ jobs:
|
||||
- name: Install Dependencies & Build
|
||||
run: |
|
||||
echo "VITE_GEMINI_API_KEY=${{ secrets.VITE_GEMINI_API_KEY }}" >> .env
|
||||
echo "GEMINI_API_KEY=${{ secrets.VITE_GEMINI_API_KEY }}" >> .env
|
||||
echo "GUIDE_JOB_SECRET=${{ secrets.GUIDE_JOB_SECRET }}" >> .env
|
||||
bun i --frozen-lockfile
|
||||
bun run build
|
||||
|
||||
- name: Configure Pages Secrets
|
||||
run: |
|
||||
printf '%s' "$GEMINI_API_KEY" | bunx wrangler pages secret put GEMINI_API_KEY --project-name=hotfridge
|
||||
printf '%s' "$GUIDE_JOB_SECRET" | bunx wrangler pages secret put GUIDE_JOB_SECRET --project-name=hotfridge
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GEMINI_API_KEY: ${{ secrets.VITE_GEMINI_API_KEY }}
|
||||
GUIDE_JOB_SECRET: ${{ secrets.GUIDE_JOB_SECRET }}
|
||||
|
||||
- name: Apply D1 Migrations
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: d1 migrations apply hotfridge --remote
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: pages deploy .svelte-kit/cloudflare --project-name=hotfridge
|
||||
|
||||
- name: Deploy Recommendation Workflow
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: deploy --config wrangler.workflow.jsonc
|
||||
secrets: |
|
||||
GUIDE_JOB_SECRET
|
||||
env:
|
||||
GUIDE_JOB_SECRET: ${{ secrets.GUIDE_JOB_SECRET }}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"deploy:workflow": "wrangler deploy --config wrangler.workflow.jsonc",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"lint": "oxlint .",
|
||||
|
||||
Vendored
+2
@@ -21,6 +21,8 @@ declare global {
|
||||
interface Platform {
|
||||
env: {
|
||||
DB: D1Database;
|
||||
GEMINI_API_KEY?: string;
|
||||
GUIDE_JOB_SECRET?: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+28
-9
@@ -1,14 +1,33 @@
|
||||
import { defineRelations } from "drizzle-orm";
|
||||
|
||||
import { tImages, tItems, type TImage, type TItem } from "./schema";
|
||||
import {
|
||||
tImages,
|
||||
tItems,
|
||||
tRecommendationBatches,
|
||||
tRecommendations,
|
||||
type TImage,
|
||||
type TItem,
|
||||
} from "./schema";
|
||||
|
||||
export const relations = defineRelations({ tItems, tImages }, (r) => ({
|
||||
tItems: {
|
||||
icon: r.one.tImages({
|
||||
from: r.tItems.iconId,
|
||||
to: r.tImages.id,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
export const relations = defineRelations(
|
||||
{ tItems, tImages, tRecommendationBatches, tRecommendations },
|
||||
(r) => ({
|
||||
tItems: {
|
||||
icon: r.one.tImages({
|
||||
from: r.tItems.iconId,
|
||||
to: r.tImages.id,
|
||||
}),
|
||||
},
|
||||
tRecommendationBatches: {
|
||||
recommendations: r.many.tRecommendations(),
|
||||
},
|
||||
tRecommendations: {
|
||||
batch: r.one.tRecommendationBatches({
|
||||
from: r.tRecommendations.batchId,
|
||||
to: r.tRecommendationBatches.id,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export type WithIcon<T extends TItem> = T & { icon: TImage | null };
|
||||
|
||||
@@ -3,6 +3,10 @@ import { sqliteTable } from "drizzle-orm/sqlite-core/table";
|
||||
|
||||
export type ItemId = number & { __brand: "item_id" };
|
||||
export type ImageId = number & { __brand: "image_id" };
|
||||
export type RecommendationBatchId = number & {
|
||||
__brand: "recommendation_batch_id";
|
||||
};
|
||||
export type RecommendationId = number & { __brand: "recommendation_id" };
|
||||
|
||||
export const tItems = sqliteTable("items", {
|
||||
id: integer().$type<ItemId>().primaryKey({ autoIncrement: true }),
|
||||
@@ -22,5 +26,47 @@ export const tImages = sqliteTable("images", {
|
||||
size: integer().notNull(),
|
||||
});
|
||||
|
||||
export const tRecommendationBatches = sqliteTable("recommendation_batches", {
|
||||
id: integer().$type<RecommendationBatchId>().primaryKey({ autoIncrement: true }),
|
||||
sessionId: text("session_id").notNull(),
|
||||
locale: text().notNull(),
|
||||
generatedAt: integer("generated_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
|
||||
export const tRecommendations = sqliteTable("recommendations", {
|
||||
id: integer().$type<RecommendationId>().primaryKey({ autoIncrement: true }),
|
||||
batchId: integer("batch_id")
|
||||
.$type<RecommendationBatchId>()
|
||||
.notNull()
|
||||
.references(() => tRecommendationBatches.id, {
|
||||
onUpdate: "cascade",
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
tier: text().notNull(),
|
||||
title: text().notNull(),
|
||||
description: text().notNull(),
|
||||
cookMinutes: integer("cook_minutes").notNull(),
|
||||
instructions: text().notNull(),
|
||||
ingredients: text().notNull(),
|
||||
imageUrl: text("image_url"),
|
||||
});
|
||||
|
||||
export const tRecommendationJobs = sqliteTable("recommendation_jobs", {
|
||||
sessionId: text("session_id").primaryKey(),
|
||||
locale: text().notNull().default("en"),
|
||||
dueAt: integer("due_at", { mode: "timestamp" }).notNull(),
|
||||
attempts: integer().notNull().default(0),
|
||||
revision: integer().notNull().default(1),
|
||||
rateLimitKey: text("rate_limit_key").notNull().default("unknown"),
|
||||
lastError: text("last_error"),
|
||||
});
|
||||
|
||||
export const tRecommendationRateLimits = sqliteTable("recommendation_rate_limits", {
|
||||
key: text().primaryKey(),
|
||||
windowStartedAt: integer("window_started_at", { mode: "timestamp" }).notNull(),
|
||||
count: integer().notNull().default(0),
|
||||
});
|
||||
|
||||
export type TItem = typeof tItems.$inferSelect;
|
||||
export type TImage = typeof tImages.$inferSelect;
|
||||
export type TRecommendation = typeof tRecommendations.$inferSelect;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE `recommendation_batches` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT,
|
||||
`session_id` text NOT NULL,
|
||||
`locale` text NOT NULL,
|
||||
`generated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `recommendation_jobs` (
|
||||
`session_id` text PRIMARY KEY,
|
||||
`locale` text DEFAULT 'en' NOT NULL,
|
||||
`due_at` integer NOT NULL,
|
||||
`attempts` integer DEFAULT 0 NOT NULL,
|
||||
`last_error` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `recommendations` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT,
|
||||
`batch_id` integer NOT NULL,
|
||||
`tier` text NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`description` text NOT NULL,
|
||||
`cook_minutes` integer NOT NULL,
|
||||
`instructions` text NOT NULL,
|
||||
`ingredients` text NOT NULL,
|
||||
`image_url` text,
|
||||
CONSTRAINT `fk_recommendations_batch_id_recommendation_batches_id_fk` FOREIGN KEY (`batch_id`) REFERENCES `recommendation_batches`(`id`) ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,386 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "14fc125c-3c68-42d8-93f5-23fc9f580a4d",
|
||||
"prevIds": [
|
||||
"2ae7f3f4-d589-4da7-b78a-392fd615f656"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "images",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "items",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "recommendation_batches",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "recommendation_jobs",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "recommendations",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": true,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "images"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "mime",
|
||||
"entityType": "columns",
|
||||
"table": "images"
|
||||
},
|
||||
{
|
||||
"type": "blob",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "data",
|
||||
"entityType": "columns",
|
||||
"table": "images"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "size",
|
||||
"entityType": "columns",
|
||||
"table": "images"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": true,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "icon_id",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "name",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "expiry",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "category",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": true,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_batches"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_batches"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "locale",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_batches"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "generated_at",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_batches"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'en'",
|
||||
"generated": null,
|
||||
"name": "locale",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "due_at",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "0",
|
||||
"generated": null,
|
||||
"name": "attempts",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "last_error",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": true,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "batch_id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "tier",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "title",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "description",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "cook_minutes",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "instructions",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "ingredients",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "image_url",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"icon_id"
|
||||
],
|
||||
"tableTo": "images",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "CASCADE",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_items_icon_id_images_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"batch_id"
|
||||
],
|
||||
"tableTo": "recommendation_batches",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "CASCADE",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_recommendations_batch_id_recommendation_batches_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "images_pk",
|
||||
"table": "images",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "items_pk",
|
||||
"table": "items",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "recommendation_batches_pk",
|
||||
"table": "recommendation_batches",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "recommendation_jobs_pk",
|
||||
"table": "recommendation_jobs",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "recommendations_pk",
|
||||
"table": "recommendations",
|
||||
"entityType": "pks"
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `recommendation_jobs` ADD `revision` integer DEFAULT 1 NOT NULL;
|
||||
@@ -0,0 +1,396 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "f6169acd-f73e-4e45-9ef8-b35a9e38d6bc",
|
||||
"prevIds": [
|
||||
"14fc125c-3c68-42d8-93f5-23fc9f580a4d"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "images",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "items",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "recommendation_batches",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "recommendation_jobs",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "recommendations",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": true,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "images"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "mime",
|
||||
"entityType": "columns",
|
||||
"table": "images"
|
||||
},
|
||||
{
|
||||
"type": "blob",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "data",
|
||||
"entityType": "columns",
|
||||
"table": "images"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "size",
|
||||
"entityType": "columns",
|
||||
"table": "images"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": true,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "icon_id",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "name",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "expiry",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "category",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": true,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_batches"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_batches"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "locale",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_batches"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "generated_at",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_batches"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'en'",
|
||||
"generated": null,
|
||||
"name": "locale",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "due_at",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "0",
|
||||
"generated": null,
|
||||
"name": "attempts",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "1",
|
||||
"generated": null,
|
||||
"name": "revision",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "last_error",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": true,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "batch_id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "tier",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "title",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "description",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "cook_minutes",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "instructions",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "ingredients",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "image_url",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"icon_id"
|
||||
],
|
||||
"tableTo": "images",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "CASCADE",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_items_icon_id_images_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"batch_id"
|
||||
],
|
||||
"tableTo": "recommendation_batches",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "CASCADE",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_recommendations_batch_id_recommendation_batches_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "images_pk",
|
||||
"table": "images",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "items_pk",
|
||||
"table": "items",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "recommendation_batches_pk",
|
||||
"table": "recommendation_batches",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "recommendation_jobs_pk",
|
||||
"table": "recommendation_jobs",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "recommendations_pk",
|
||||
"table": "recommendations",
|
||||
"entityType": "pks"
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE `recommendation_rate_limits` (
|
||||
`key` text PRIMARY KEY,
|
||||
`window_started_at` integer NOT NULL,
|
||||
`count` integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,439 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "7b679a63-4e38-4c77-9596-a5b8fc36512d",
|
||||
"prevIds": [
|
||||
"f6169acd-f73e-4e45-9ef8-b35a9e38d6bc"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "images",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "items",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "recommendation_batches",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "recommendation_jobs",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "recommendation_rate_limits",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "recommendations",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": true,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "images"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "mime",
|
||||
"entityType": "columns",
|
||||
"table": "images"
|
||||
},
|
||||
{
|
||||
"type": "blob",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "data",
|
||||
"entityType": "columns",
|
||||
"table": "images"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "size",
|
||||
"entityType": "columns",
|
||||
"table": "images"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": true,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "icon_id",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "name",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "expiry",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "category",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": true,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_batches"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_batches"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "locale",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_batches"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "generated_at",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_batches"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'en'",
|
||||
"generated": null,
|
||||
"name": "locale",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "due_at",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "0",
|
||||
"generated": null,
|
||||
"name": "attempts",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "1",
|
||||
"generated": null,
|
||||
"name": "revision",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "last_error",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "key",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_rate_limits"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "window_started_at",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_rate_limits"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "0",
|
||||
"generated": null,
|
||||
"name": "count",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_rate_limits"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": true,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "batch_id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "tier",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "title",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "description",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "cook_minutes",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "instructions",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "ingredients",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "image_url",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"icon_id"
|
||||
],
|
||||
"tableTo": "images",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "CASCADE",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_items_icon_id_images_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"batch_id"
|
||||
],
|
||||
"tableTo": "recommendation_batches",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "CASCADE",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_recommendations_batch_id_recommendation_batches_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "images_pk",
|
||||
"table": "images",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "items_pk",
|
||||
"table": "items",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "recommendation_batches_pk",
|
||||
"table": "recommendation_batches",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "recommendation_jobs_pk",
|
||||
"table": "recommendation_jobs",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"key"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "recommendation_rate_limits_pk",
|
||||
"table": "recommendation_rate_limits",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "recommendations_pk",
|
||||
"table": "recommendations",
|
||||
"entityType": "pks"
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `recommendation_jobs` ADD `rate_limit_key` text DEFAULT 'unknown' NOT NULL;
|
||||
@@ -0,0 +1,449 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "cd16457a-37ac-4d7b-9fc7-b10efa42c7e7",
|
||||
"prevIds": [
|
||||
"7b679a63-4e38-4c77-9596-a5b8fc36512d"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "images",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "items",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "recommendation_batches",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "recommendation_jobs",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "recommendation_rate_limits",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "recommendations",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": true,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "images"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "mime",
|
||||
"entityType": "columns",
|
||||
"table": "images"
|
||||
},
|
||||
{
|
||||
"type": "blob",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "data",
|
||||
"entityType": "columns",
|
||||
"table": "images"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "size",
|
||||
"entityType": "columns",
|
||||
"table": "images"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": true,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "icon_id",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "name",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "expiry",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "category",
|
||||
"entityType": "columns",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": true,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_batches"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_batches"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "locale",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_batches"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "generated_at",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_batches"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'en'",
|
||||
"generated": null,
|
||||
"name": "locale",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "due_at",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "0",
|
||||
"generated": null,
|
||||
"name": "attempts",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "1",
|
||||
"generated": null,
|
||||
"name": "revision",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'unknown'",
|
||||
"generated": null,
|
||||
"name": "rate_limit_key",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "last_error",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_jobs"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "key",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_rate_limits"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "window_started_at",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_rate_limits"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "0",
|
||||
"generated": null,
|
||||
"name": "count",
|
||||
"entityType": "columns",
|
||||
"table": "recommendation_rate_limits"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": true,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "batch_id",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "tier",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "title",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "description",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "cook_minutes",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "instructions",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "ingredients",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "image_url",
|
||||
"entityType": "columns",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"icon_id"
|
||||
],
|
||||
"tableTo": "images",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "CASCADE",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_items_icon_id_images_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "items"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"batch_id"
|
||||
],
|
||||
"tableTo": "recommendation_batches",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "CASCADE",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_recommendations_batch_id_recommendation_batches_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "recommendations"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "images_pk",
|
||||
"table": "images",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "items_pk",
|
||||
"table": "items",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "recommendation_batches_pk",
|
||||
"table": "recommendation_batches",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "recommendation_jobs_pk",
|
||||
"table": "recommendation_jobs",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"key"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "recommendation_rate_limits_pk",
|
||||
"table": "recommendation_rate_limits",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "recommendations_pk",
|
||||
"table": "recommendations",
|
||||
"entityType": "pks"
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
}
|
||||
+33
-9
@@ -1,6 +1,14 @@
|
||||
import { command, getRequestEvent, query } from "$app/server";
|
||||
import { tImages, tItems, type ItemId } from "$lib/db/schema";
|
||||
import {
|
||||
tImages,
|
||||
tItems,
|
||||
type ItemId,
|
||||
} from "$lib/db/schema";
|
||||
import { getDb } from "$lib/server/db";
|
||||
import {
|
||||
getRecommendationRateLimitKey,
|
||||
scheduleRecommendationJob,
|
||||
} from "$lib/server/recommendations";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const getItems = query(() => {
|
||||
@@ -22,14 +30,20 @@ export const getItem = query("unchecked", (id: ItemId) => {
|
||||
export const addItems = command(
|
||||
"unchecked",
|
||||
async (
|
||||
items: {
|
||||
name: string;
|
||||
category: string;
|
||||
expiry: Date;
|
||||
icon: { mime: string; data: Uint8Array; size: number };
|
||||
}[],
|
||||
{
|
||||
items,
|
||||
locale,
|
||||
}: {
|
||||
items: {
|
||||
name: string;
|
||||
category: string;
|
||||
expiry: Date;
|
||||
icon: { mime: string; data: Uint8Array; size: number };
|
||||
}[];
|
||||
locale: string;
|
||||
},
|
||||
): Promise<ItemId[]> => {
|
||||
const { locals } = getRequestEvent();
|
||||
const { locals, request } = getRequestEvent();
|
||||
const db = getDb();
|
||||
const ids: ItemId[] = [];
|
||||
|
||||
@@ -47,12 +61,18 @@ export const addItems = command(
|
||||
ids.push(inserted.id);
|
||||
}
|
||||
|
||||
await scheduleRecommendationJob(db, locals.sessionId, {
|
||||
locale,
|
||||
delay: 5 * 60 * 1000,
|
||||
rateLimitKey: await getRecommendationRateLimitKey(request),
|
||||
});
|
||||
|
||||
return ids;
|
||||
},
|
||||
);
|
||||
|
||||
export const removeItem = command("unchecked", async (id: ItemId) => {
|
||||
const { locals } = getRequestEvent();
|
||||
const { locals, request } = getRequestEvent();
|
||||
const db = getDb();
|
||||
const item = await db.query.tItems.findFirst({
|
||||
where: { id: { eq: id }, sessionId: { eq: locals.sessionId } },
|
||||
@@ -65,4 +85,8 @@ export const removeItem = command("unchecked", async (id: ItemId) => {
|
||||
} else {
|
||||
await db.delete(tItems).where(eq(tItems.id, id));
|
||||
}
|
||||
await scheduleRecommendationJob(db, locals.sessionId, {
|
||||
delay: 5 * 60 * 1000,
|
||||
rateLimitKey: await getRecommendationRateLimitKey(request),
|
||||
});
|
||||
});
|
||||
|
||||
+27
-1
@@ -34,7 +34,33 @@
|
||||
"saving": "Saving...",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"reload": "Reload"
|
||||
"reload": "Reload",
|
||||
"close": "Close"
|
||||
},
|
||||
"guide": {
|
||||
"eyebrow": "Your kitchen, planned",
|
||||
"title": "What should we cook?",
|
||||
"description": "Fresh ideas built around what is in your fridge and what should be used first.",
|
||||
"refresh": "Refresh recommendations",
|
||||
"generating": "Planning your next meals",
|
||||
"generating_note": "This can take a moment while we consider your fridge.",
|
||||
"generation_failed": "We couldn't prepare recommendations right now.",
|
||||
"view_recipe": "View recipe",
|
||||
"minutes": "{count} minutes",
|
||||
"ingredients": "Ingredients",
|
||||
"instructions": "Instructions",
|
||||
"tier": {
|
||||
"near_expiry_food": "Use these first",
|
||||
"near_expiry_ingredients": "Rescue these ingredients",
|
||||
"discovery": "Something different",
|
||||
"fridge": "More from your fridge"
|
||||
},
|
||||
"tier_note": {
|
||||
"near_expiry_food": "Meals centered on food closest to its expiry.",
|
||||
"near_expiry_ingredients": "Ideas that put vulnerable ingredients to work.",
|
||||
"discovery": "Interesting dishes for when you want a change.",
|
||||
"fridge": "Good combinations from everything else you have."
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"food": "Food",
|
||||
|
||||
+27
-1
@@ -34,7 +34,33 @@
|
||||
"saving": "กำลังบันทึก...",
|
||||
"cancel": "ยกเลิก",
|
||||
"delete": "ลบ",
|
||||
"reload": "รีโหลด"
|
||||
"reload": "รีโหลด",
|
||||
"close": "ปิด"
|
||||
},
|
||||
"guide": {
|
||||
"eyebrow": "วางแผนจากของในครัว",
|
||||
"title": "วันนี้ทำอะไรกินดี?",
|
||||
"description": "เมนูแนะนำจากของในตู้เย็น โดยเน้นวัตถุดิบที่ควรใช้ก่อน",
|
||||
"refresh": "สร้างคำแนะนำใหม่",
|
||||
"generating": "กำลังวางแผนมื้อถัดไป",
|
||||
"generating_note": "ระบบกำลังพิจารณาของในตู้เย็น อาจใช้เวลาสักครู่",
|
||||
"generation_failed": "ไม่สามารถสร้างคำแนะนำได้ในขณะนี้",
|
||||
"view_recipe": "ดูสูตรอาหาร",
|
||||
"minutes": "{count} นาที",
|
||||
"ingredients": "วัตถุดิบ",
|
||||
"instructions": "วิธีทำ",
|
||||
"tier": {
|
||||
"near_expiry_food": "ควรใช้ก่อน",
|
||||
"near_expiry_ingredients": "ช่วยวัตถุดิบเหล่านี้",
|
||||
"discovery": "ลองอะไรใหม่ๆ",
|
||||
"fridge": "เมนูจากของที่เหลือ"
|
||||
},
|
||||
"tier_note": {
|
||||
"near_expiry_food": "เมนูจากอาหารที่ใกล้หมดอายุมากที่สุด",
|
||||
"near_expiry_ingredients": "ไอเดียสำหรับใช้วัตถุดิบที่ควรรีบใช้",
|
||||
"discovery": "เมนูน่าสนใจสำหรับวันที่อยากลองสิ่งใหม่",
|
||||
"fridge": "จับคู่ของอื่นๆ ที่มีอยู่ในตู้เย็น"
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"food": "อาหาร",
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { command, getRequestEvent, query } from "$app/server";
|
||||
import {
|
||||
tRecommendationBatches,
|
||||
tRecommendationJobs,
|
||||
tRecommendations,
|
||||
} from "$lib/db/schema";
|
||||
import { getDb } from "$lib/server/db";
|
||||
import {
|
||||
getRecommendationApiKey,
|
||||
getRecommendationRateLimitKey,
|
||||
RecommendationRateLimitError,
|
||||
runRecommendationJob,
|
||||
scheduleRecommendationJob,
|
||||
} from "$lib/server/recommendations";
|
||||
import type { MenuIngredient, MenuTier } from "$lib/recommendations";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { error } from "@sveltejs/kit";
|
||||
|
||||
export const getRecommendations = query(async () => {
|
||||
const { locals } = getRequestEvent();
|
||||
const db = getDb();
|
||||
const [[batch], [job]] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(tRecommendationBatches)
|
||||
.where(eq(tRecommendationBatches.sessionId, locals.sessionId))
|
||||
.orderBy(desc(tRecommendationBatches.generatedAt))
|
||||
.limit(1),
|
||||
db
|
||||
.select({ attempts: tRecommendationJobs.attempts })
|
||||
.from(tRecommendationJobs)
|
||||
.where(eq(tRecommendationJobs.sessionId, locals.sessionId))
|
||||
.limit(1),
|
||||
]);
|
||||
|
||||
if (!batch) {
|
||||
return {
|
||||
generatedAt: null,
|
||||
pending: Boolean(job && job.attempts < 3),
|
||||
menus: [],
|
||||
};
|
||||
}
|
||||
|
||||
const [rows, items] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(tRecommendations)
|
||||
.where(eq(tRecommendations.batchId, batch.id)),
|
||||
db.query.tItems.findMany({
|
||||
with: { icon: true },
|
||||
where: { sessionId: { eq: locals.sessionId } },
|
||||
}),
|
||||
]);
|
||||
const itemById = new Map(items.map((item) => [Number(item.id), item]));
|
||||
|
||||
return {
|
||||
generatedAt: batch.generatedAt,
|
||||
pending: Boolean(job && job.attempts < 3),
|
||||
menus: rows.map((row) => {
|
||||
const ingredients = JSON.parse(row.ingredients) as MenuIngredient[];
|
||||
return {
|
||||
id: row.id,
|
||||
tier: row.tier as MenuTier,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
cookMinutes: row.cookMinutes,
|
||||
imageUrl: row.imageUrl ?? undefined,
|
||||
instructions: JSON.parse(row.instructions) as string[],
|
||||
ingredients: ingredients.map((ingredient) => ({
|
||||
...ingredient,
|
||||
fridgeItem: ingredient.itemId
|
||||
? itemById.get(Number(ingredient.itemId))
|
||||
: undefined,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
export const generateRecommendations = command(
|
||||
"unchecked",
|
||||
async ({ locale, force = false }: { locale: string; force?: boolean }) => {
|
||||
const event = getRequestEvent();
|
||||
const db = getDb();
|
||||
const normalizedLocale = /^[a-z]{2}(?:-[A-Z]{2})?$/.test(locale) ? locale : "en";
|
||||
|
||||
const [existing] = await db
|
||||
.select({
|
||||
id: tRecommendationBatches.id,
|
||||
generatedAt: tRecommendationBatches.generatedAt,
|
||||
})
|
||||
.from(tRecommendationBatches)
|
||||
.where(eq(tRecommendationBatches.sessionId, event.locals.sessionId))
|
||||
.orderBy(desc(tRecommendationBatches.generatedAt))
|
||||
.limit(1);
|
||||
if (
|
||||
existing &&
|
||||
(!force || existing.generatedAt.getTime() > Date.now() - 5 * 60 * 1000)
|
||||
) {
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const [activeJob] = await db
|
||||
.select({ dueAt: tRecommendationJobs.dueAt, attempts: tRecommendationJobs.attempts })
|
||||
.from(tRecommendationJobs)
|
||||
.where(eq(tRecommendationJobs.sessionId, event.locals.sessionId))
|
||||
.limit(1);
|
||||
if (activeJob && activeJob.attempts > 0 && activeJob.dueAt > new Date()) {
|
||||
return existing?.id ?? null;
|
||||
}
|
||||
|
||||
const rateLimitKey = await getRecommendationRateLimitKey(event.request);
|
||||
|
||||
await scheduleRecommendationJob(db, event.locals.sessionId, {
|
||||
locale: normalizedLocale,
|
||||
rateLimitKey,
|
||||
});
|
||||
let id: number | null;
|
||||
try {
|
||||
id = await runRecommendationJob(
|
||||
db,
|
||||
event.locals.sessionId,
|
||||
getRecommendationApiKey(event.platform),
|
||||
);
|
||||
} catch (cause) {
|
||||
if (cause instanceof RecommendationRateLimitError) {
|
||||
error(429, "Recommendation generation limit reached. Try again later.");
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
void getRecommendations().refresh();
|
||||
return id ?? existing?.id ?? null;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ItemId, RecommendationId } from "$lib/db/schema";
|
||||
|
||||
export const menuTiers = [
|
||||
"near_expiry_food",
|
||||
"near_expiry_ingredients",
|
||||
"discovery",
|
||||
"fridge",
|
||||
] as const;
|
||||
|
||||
export type MenuTier = (typeof menuTiers)[number];
|
||||
|
||||
export type MenuIngredient = {
|
||||
amount: string;
|
||||
itemId?: ItemId;
|
||||
externalName?: string;
|
||||
};
|
||||
|
||||
export type GeneratedMenu = {
|
||||
tier: MenuTier;
|
||||
title: string;
|
||||
description: string;
|
||||
cookMinutes: number;
|
||||
instructions: string[];
|
||||
ingredients: MenuIngredient[];
|
||||
imageUrl?: string;
|
||||
};
|
||||
|
||||
export type RecommendationMenu = GeneratedMenu & {
|
||||
id: RecommendationId;
|
||||
};
|
||||
@@ -1,7 +1,12 @@
|
||||
import { getRequestEvent } from "$app/server";
|
||||
import { relations } from "$lib/db/relations";
|
||||
import type { D1Database } from "@cloudflare/workers-types";
|
||||
import { drizzle } from "drizzle-orm/d1";
|
||||
|
||||
export function createDb(database: D1Database) {
|
||||
return drizzle(database, { relations, logger: true });
|
||||
}
|
||||
|
||||
export function getDb() {
|
||||
const { platform } = getRequestEvent();
|
||||
if (!platform) {
|
||||
@@ -9,5 +14,5 @@ export function getDb() {
|
||||
"platform.env is unavailable — is the D1 binding configured in wrangler.jsonc?",
|
||||
);
|
||||
}
|
||||
return drizzle(platform.env.DB, { relations, logger: true });
|
||||
return createDb(platform.env.DB);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import {
|
||||
tItems,
|
||||
tRecommendationBatches,
|
||||
tRecommendationJobs,
|
||||
tRecommendationRateLimits,
|
||||
type ItemId,
|
||||
} from "$lib/db/schema";
|
||||
import {
|
||||
menuTiers,
|
||||
type GeneratedMenu,
|
||||
type MenuIngredient,
|
||||
type MenuTier,
|
||||
} from "$lib/recommendations";
|
||||
import { and, desc, eq, lt, lte, or, sql } from "drizzle-orm";
|
||||
import type { createDb } from "$lib/server/db";
|
||||
|
||||
type Db = ReturnType<typeof createDb>;
|
||||
|
||||
const MODEL = "gemini-3.6-flash";
|
||||
const RETRY_DELAY = 30 * 60 * 1000;
|
||||
const JOB_LEASE = 15 * 60 * 1000;
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const RATE_LIMIT_WINDOW = 60 * 60 * 1000;
|
||||
const RATE_LIMIT_MAX = 6;
|
||||
|
||||
const RESPONSE_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
menus: {
|
||||
type: "array",
|
||||
minItems: 8,
|
||||
maxItems: 16,
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tier: { type: "string", enum: menuTiers },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
cookMinutes: { type: "integer" },
|
||||
instructions: { type: "array", items: { type: "string" } },
|
||||
ingredients: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
amount: { type: "string" },
|
||||
itemId: { type: "integer", nullable: true },
|
||||
externalName: { type: "string", nullable: true },
|
||||
},
|
||||
required: ["amount"],
|
||||
},
|
||||
},
|
||||
imageUrl: { type: "string", nullable: true },
|
||||
},
|
||||
required: [
|
||||
"tier",
|
||||
"title",
|
||||
"description",
|
||||
"cookMinutes",
|
||||
"instructions",
|
||||
"ingredients",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["menus"],
|
||||
};
|
||||
|
||||
function isTier(value: unknown): value is MenuTier {
|
||||
return typeof value === "string" && menuTiers.includes(value as MenuTier);
|
||||
}
|
||||
|
||||
function normalizeMenus(value: unknown, validItemIds: Set<number>) {
|
||||
if (!value || typeof value !== "object") throw new Error("Invalid Gemini response");
|
||||
const rawMenus = (value as { menus?: unknown }).menus;
|
||||
if (!Array.isArray(rawMenus)) throw new Error("Gemini returned no menus");
|
||||
|
||||
return rawMenus.flatMap((raw): GeneratedMenu[] => {
|
||||
if (!raw || typeof raw !== "object") return [];
|
||||
const menu = raw as Record<string, unknown>;
|
||||
if (
|
||||
!isTier(menu.tier) ||
|
||||
typeof menu.title !== "string" ||
|
||||
typeof menu.description !== "string" ||
|
||||
!Number.isFinite(menu.cookMinutes) ||
|
||||
!Array.isArray(menu.instructions) ||
|
||||
!Array.isArray(menu.ingredients)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ingredients = menu.ingredients.flatMap((rawIngredient): MenuIngredient[] => {
|
||||
if (!rawIngredient || typeof rawIngredient !== "object") return [];
|
||||
const ingredient = rawIngredient as Record<string, unknown>;
|
||||
const itemId = Number(ingredient.itemId);
|
||||
const externalName =
|
||||
typeof ingredient.externalName === "string"
|
||||
? ingredient.externalName.trim()
|
||||
: "";
|
||||
const amount =
|
||||
typeof ingredient.amount === "string" ? ingredient.amount.trim() : "";
|
||||
|
||||
if (validItemIds.has(itemId)) {
|
||||
return [{ amount, itemId: itemId as ItemId }];
|
||||
}
|
||||
if (externalName) return [{ amount, externalName }];
|
||||
return [];
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
tier: menu.tier,
|
||||
title: menu.title.trim(),
|
||||
description: menu.description.trim(),
|
||||
cookMinutes: Math.max(1, Math.round(Number(menu.cookMinutes))),
|
||||
instructions: menu.instructions.filter(
|
||||
(step): step is string => typeof step === "string" && step.trim().length > 0,
|
||||
),
|
||||
ingredients,
|
||||
imageUrl:
|
||||
typeof menu.imageUrl === "string" && menu.imageUrl.startsWith("https://")
|
||||
? menu.imageUrl
|
||||
: undefined,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
async function requestMenus(
|
||||
items: (typeof tItems.$inferSelect)[],
|
||||
locale: string,
|
||||
apiKey: string,
|
||||
) {
|
||||
const inventory = items.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
expiry: item.expiry?.toISOString() ?? null,
|
||||
}));
|
||||
const prompt = [
|
||||
"You create practical recipe recommendations for a fridge inventory app.",
|
||||
`Write all user-facing content in locale ${locale}.`,
|
||||
"Return 2-4 menus in each of these exact tiers:",
|
||||
"near_expiry_food: complete meals centered on food expiring soon.",
|
||||
"near_expiry_ingredients: recipes that consume individual ingredients expiring soon.",
|
||||
"discovery: interesting unrelated dishes worth trying, even if groceries are needed.",
|
||||
"fridge: recipes that combine other available fridge items.",
|
||||
"For an ingredient already in inventory, return its numeric itemId and do not return externalName.",
|
||||
"For anything that must be bought, return externalName and no itemId.",
|
||||
"Only provide imageUrl when it is a direct, durable HTTPS image URL you are confident exists; otherwise omit it.",
|
||||
`Today is ${new Date().toISOString()}. Inventory: ${JSON.stringify(inventory)}`,
|
||||
].join("\n");
|
||||
|
||||
const response = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${apiKey}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
generationConfig: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: RESPONSE_SCHEMA,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gemini request failed (${response.status}): ${await response.text()}`);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {
|
||||
candidates?: { content?: { parts?: { text?: string }[] } }[];
|
||||
};
|
||||
const text = body.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||
if (!text) throw new Error("Gemini returned no recommendation content");
|
||||
|
||||
const menus = normalizeMenus(
|
||||
JSON.parse(text),
|
||||
new Set(items.map((item) => Number(item.id))),
|
||||
);
|
||||
if (menus.length === 0) throw new Error("Gemini returned no valid menus");
|
||||
return menus;
|
||||
}
|
||||
|
||||
export function getRecommendationApiKey(platform?: App.Platform) {
|
||||
const key = platform?.env.GEMINI_API_KEY ?? process.env.GEMINI_API_KEY;
|
||||
if (!key) throw new Error("GEMINI_API_KEY is not configured");
|
||||
return key;
|
||||
}
|
||||
|
||||
export class RecommendationRateLimitError extends Error {}
|
||||
|
||||
export async function getRecommendationRateLimitKey(request: Request) {
|
||||
const source =
|
||||
request.headers.get("cf-connecting-ip") ??
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
"unknown";
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(source),
|
||||
);
|
||||
return [...new Uint8Array(digest)]
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function consumeRecommendationQuota(db: Db, key: string) {
|
||||
const now = new Date();
|
||||
const cutoff = new Date(now.getTime() - RATE_LIMIT_WINDOW);
|
||||
await db
|
||||
.insert(tRecommendationRateLimits)
|
||||
.values({ key, windowStartedAt: now, count: 0 })
|
||||
.onConflictDoNothing();
|
||||
const [updated] = await db
|
||||
.update(tRecommendationRateLimits)
|
||||
.set({
|
||||
count: sql`case when ${tRecommendationRateLimits.windowStartedAt} <= ${cutoff} then 1 else ${tRecommendationRateLimits.count} + 1 end`,
|
||||
windowStartedAt: sql`case when ${tRecommendationRateLimits.windowStartedAt} <= ${cutoff} then ${now} else ${tRecommendationRateLimits.windowStartedAt} end`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(tRecommendationRateLimits.key, key),
|
||||
or(
|
||||
lte(tRecommendationRateLimits.windowStartedAt, cutoff),
|
||||
lt(tRecommendationRateLimits.count, RATE_LIMIT_MAX),
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning({ count: tRecommendationRateLimits.count });
|
||||
return Boolean(updated);
|
||||
}
|
||||
|
||||
export async function generateRecommendationsForSession(
|
||||
db: Db,
|
||||
sessionId: string,
|
||||
locale: string,
|
||||
apiKey: string,
|
||||
revision: number,
|
||||
) {
|
||||
const items = await db.select().from(tItems).where(eq(tItems.sessionId, sessionId));
|
||||
const menus = await requestMenus(items, locale, apiKey);
|
||||
const batchId = (crypto.getRandomValues(new Uint32Array(1))[0] & 0x7fffffff) || 1;
|
||||
const statements = [
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT INTO recommendation_batches (id, session_id, locale, generated_at) " +
|
||||
"SELECT ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM recommendation_jobs WHERE session_id = ? AND revision = ?)",
|
||||
)
|
||||
.bind(batchId, sessionId, locale, Math.floor(Date.now() / 1000), sessionId, revision),
|
||||
...menus.map((menu) =>
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT INTO recommendations " +
|
||||
"(batch_id, tier, title, description, cook_minutes, instructions, ingredients, image_url) " +
|
||||
"SELECT ?, ?, ?, ?, ?, ?, ?, ? WHERE EXISTS " +
|
||||
"(SELECT 1 FROM recommendation_batches WHERE id = ?)",
|
||||
)
|
||||
.bind(
|
||||
batchId,
|
||||
menu.tier,
|
||||
menu.title,
|
||||
menu.description,
|
||||
menu.cookMinutes,
|
||||
JSON.stringify(menu.instructions),
|
||||
JSON.stringify(menu.ingredients),
|
||||
menu.imageUrl ?? null,
|
||||
batchId,
|
||||
),
|
||||
),
|
||||
db.$client
|
||||
.prepare(
|
||||
"DELETE FROM recommendation_batches WHERE session_id = ? AND id <> ? " +
|
||||
"AND EXISTS (SELECT 1 FROM recommendation_batches WHERE id = ?)",
|
||||
)
|
||||
.bind(sessionId, batchId, batchId),
|
||||
db.$client
|
||||
.prepare("DELETE FROM recommendation_jobs WHERE session_id = ? AND revision = ?")
|
||||
.bind(sessionId, revision),
|
||||
];
|
||||
const results = await db.$client.batch(statements);
|
||||
return results[0]?.meta.changes ? batchId : null;
|
||||
}
|
||||
|
||||
export async function scheduleRecommendationJob(
|
||||
db: Db,
|
||||
sessionId: string,
|
||||
options: { locale?: string; delay?: number; rateLimitKey?: string } = {},
|
||||
) {
|
||||
let locale = options.locale;
|
||||
if (!locale) {
|
||||
const [latest] = await db
|
||||
.select({ locale: tRecommendationBatches.locale })
|
||||
.from(tRecommendationBatches)
|
||||
.where(eq(tRecommendationBatches.sessionId, sessionId))
|
||||
.orderBy(desc(tRecommendationBatches.generatedAt))
|
||||
.limit(1);
|
||||
locale = latest?.locale ?? "en";
|
||||
}
|
||||
const normalizedLocale = /^[a-z]{2}(?:-[A-Z]{2})?$/.test(locale) ? locale : "en";
|
||||
const dueAt = new Date(Date.now() + (options.delay ?? 0));
|
||||
|
||||
await db
|
||||
.insert(tRecommendationJobs)
|
||||
.values({
|
||||
sessionId,
|
||||
locale: normalizedLocale,
|
||||
dueAt,
|
||||
rateLimitKey: options.rateLimitKey ?? "unknown",
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: tRecommendationJobs.sessionId,
|
||||
set: {
|
||||
locale: normalizedLocale,
|
||||
dueAt,
|
||||
attempts: 0,
|
||||
revision: sql`${tRecommendationJobs.revision} + 1`,
|
||||
rateLimitKey: options.rateLimitKey ?? "unknown",
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function claimRecommendationJob(db: Db, sessionId: string) {
|
||||
const [job] = await db
|
||||
.update(tRecommendationJobs)
|
||||
.set({
|
||||
dueAt: new Date(Date.now() + JOB_LEASE),
|
||||
attempts: sql`${tRecommendationJobs.attempts} + 1`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(tRecommendationJobs.sessionId, sessionId),
|
||||
lte(tRecommendationJobs.dueAt, new Date()),
|
||||
lt(tRecommendationJobs.attempts, MAX_ATTEMPTS),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return job;
|
||||
}
|
||||
|
||||
export async function runRecommendationJob(
|
||||
db: Db,
|
||||
sessionId: string,
|
||||
apiKey: string,
|
||||
) {
|
||||
const job = await claimRecommendationJob(db, sessionId);
|
||||
if (!job) return null;
|
||||
|
||||
try {
|
||||
if (!(await consumeRecommendationQuota(db, job.rateLimitKey))) {
|
||||
await db
|
||||
.update(tRecommendationJobs)
|
||||
.set({ attempts: MAX_ATTEMPTS, lastError: "Generation rate limit reached" })
|
||||
.where(
|
||||
and(
|
||||
eq(tRecommendationJobs.sessionId, job.sessionId),
|
||||
eq(tRecommendationJobs.revision, job.revision),
|
||||
),
|
||||
);
|
||||
throw new RecommendationRateLimitError("Generation rate limit reached");
|
||||
}
|
||||
return await generateRecommendationsForSession(
|
||||
db,
|
||||
job.sessionId,
|
||||
job.locale,
|
||||
apiKey,
|
||||
job.revision,
|
||||
);
|
||||
} catch (error) {
|
||||
await db
|
||||
.update(tRecommendationJobs)
|
||||
.set({
|
||||
dueAt: new Date(Date.now() + RETRY_DELAY),
|
||||
lastError: error instanceof Error ? error.message.slice(0, 1000) : String(error),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(tRecommendationJobs.sessionId, job.sessionId),
|
||||
eq(tRecommendationJobs.revision, job.revision),
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function processDueRecommendationJobs(db: Db, apiKey: string) {
|
||||
const jobs = await db
|
||||
.select()
|
||||
.from(tRecommendationJobs)
|
||||
.where(
|
||||
and(
|
||||
lte(tRecommendationJobs.dueAt, new Date()),
|
||||
lt(tRecommendationJobs.attempts, MAX_ATTEMPTS),
|
||||
),
|
||||
)
|
||||
.limit(10);
|
||||
let completed = 0;
|
||||
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
if ((await runRecommendationJob(db, job.sessionId, apiKey)) !== null) {
|
||||
completed += 1;
|
||||
}
|
||||
} catch {
|
||||
// The claimed job records its own failure and retry time.
|
||||
}
|
||||
}
|
||||
|
||||
return { processed: jobs.length, completed };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createDb } from "$lib/server/db";
|
||||
import {
|
||||
getRecommendationApiKey,
|
||||
processDueRecommendationJobs,
|
||||
} from "$lib/server/recommendations";
|
||||
import { json, type RequestHandler } from "@sveltejs/kit";
|
||||
|
||||
export const POST: RequestHandler = async ({ request, platform }) => {
|
||||
if (!platform) return json({ error: "Cloudflare platform unavailable" }, { status: 503 });
|
||||
|
||||
const secret = platform.env.GUIDE_JOB_SECRET ?? process.env.GUIDE_JOB_SECRET;
|
||||
if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) {
|
||||
return json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const result = await processDueRecommendationJobs(
|
||||
createDb(platform.env.DB),
|
||||
getRecommendationApiKey(platform),
|
||||
);
|
||||
return json(result);
|
||||
};
|
||||
+274
-126
@@ -1,157 +1,305 @@
|
||||
<script lang="ts">
|
||||
import BookImageIcon from "@lucide/svelte/icons/book-image";
|
||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||
import Image from "$lib/components/image.svelte";
|
||||
import Badge from "$lib/components/ui/badge/badge.svelte";
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
import type { WithIcon } from "$lib/db/relations";
|
||||
import type { TItem } from "$lib/db/schema";
|
||||
import {
|
||||
generateRecommendations,
|
||||
getRecommendations,
|
||||
} from "$lib/recommendations.remote";
|
||||
import type { MenuIngredient, MenuTier } from "$lib/recommendations";
|
||||
import { toImageUrl } from "$lib/utils";
|
||||
import ChefHatIcon from "@lucide/svelte/icons/chef-hat";
|
||||
import Clock3Icon from "@lucide/svelte/icons/clock-3";
|
||||
import SnowflakeIcon from "@lucide/svelte/icons/snowflake";
|
||||
import ImageOffIcon from "@lucide/svelte/icons/image-off";
|
||||
import LoaderCircleIcon from "@lucide/svelte/icons/loader-circle";
|
||||
import RefreshCwIcon from "@lucide/svelte/icons/refresh-cw";
|
||||
import SparklesIcon from "@lucide/svelte/icons/sparkles";
|
||||
import TriangleAlertIcon from "@lucide/svelte/icons/triangle-alert";
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
import { onMount } from "svelte";
|
||||
import { _, locale } from "svelte-i18n";
|
||||
|
||||
const quickGuides = [
|
||||
type DisplayIngredient = MenuIngredient & {
|
||||
fridgeItem?: WithIcon<TItem>;
|
||||
};
|
||||
|
||||
type DisplayMenu = {
|
||||
id: number;
|
||||
tier: MenuTier;
|
||||
title: string;
|
||||
description: string;
|
||||
cookMinutes: number;
|
||||
imageUrl?: string;
|
||||
instructions: string[];
|
||||
ingredients: DisplayIngredient[];
|
||||
};
|
||||
|
||||
const tiers: { id: MenuTier; label: string; note: string }[] = [
|
||||
{
|
||||
title: "Leafy greens",
|
||||
note: "Wrap with paper before chilling.",
|
||||
time: "3-5 days",
|
||||
accent: "bg-emerald-500/12 text-emerald-700 dark:text-emerald-300"
|
||||
id: "near_expiry_food",
|
||||
label: "guide.tier.near_expiry_food",
|
||||
note: "guide.tier_note.near_expiry_food",
|
||||
},
|
||||
{
|
||||
title: "Cooked rice",
|
||||
note: "Seal fast while still slightly warm.",
|
||||
time: "1-2 days",
|
||||
accent: "bg-amber-500/12 text-amber-700 dark:text-amber-300"
|
||||
id: "near_expiry_ingredients",
|
||||
label: "guide.tier.near_expiry_ingredients",
|
||||
note: "guide.tier_note.near_expiry_ingredients",
|
||||
},
|
||||
{
|
||||
title: "Fresh meat",
|
||||
note: "Keep on the coldest shelf, bottom tray.",
|
||||
time: "1-2 days",
|
||||
accent: "bg-rose-500/12 text-rose-700 dark:text-rose-300"
|
||||
id: "discovery",
|
||||
label: "guide.tier.discovery",
|
||||
note: "guide.tier_note.discovery",
|
||||
},
|
||||
{
|
||||
title: "Sauces",
|
||||
note: "Door shelf is fine once opened.",
|
||||
time: "1 month",
|
||||
accent: "bg-sky-500/12 text-sky-700 dark:text-sky-300"
|
||||
id: "fridge",
|
||||
label: "guide.tier.fridge",
|
||||
note: "guide.tier_note.fridge",
|
||||
},
|
||||
];
|
||||
|
||||
const recommendations = getRecommendations();
|
||||
let selected = $state<DisplayMenu>();
|
||||
let generating = $state(false);
|
||||
let generationError = $state(false);
|
||||
let mounted = false;
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function pollPendingRecommendations() {
|
||||
let current = recommendations.current;
|
||||
while (mounted && current?.pending && current.menus.length === 0) {
|
||||
await wait(5000);
|
||||
if (!mounted) return;
|
||||
await recommendations.refresh();
|
||||
current = recommendations.current;
|
||||
}
|
||||
];
|
||||
|
||||
const zones = [
|
||||
{
|
||||
title: "Top shelf",
|
||||
items: "Leftovers, drinks, ready-to-eat meals",
|
||||
tone: "Safe and easy to grab"
|
||||
},
|
||||
{
|
||||
title: "Bottom shelf",
|
||||
items: "Raw meat, fish, meal prep boxes",
|
||||
tone: "Coldest zone"
|
||||
},
|
||||
{
|
||||
title: "Crisper",
|
||||
items: "Vegetables, herbs, fruit with airflow",
|
||||
tone: "Humidity matters"
|
||||
},
|
||||
{
|
||||
title: "Door",
|
||||
items: "Condiments, jam, butter, soda",
|
||||
tone: "Warmest spot"
|
||||
if (mounted && current && !current.pending && current.menus.length === 0) {
|
||||
generationError = true;
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
const warnings = [
|
||||
"Milk beside the door expires faster.",
|
||||
"Hot food raises the whole fridge temp.",
|
||||
"Uncovered leftovers dry out and smell spreads.",
|
||||
"Overpacking blocks cold air circulation."
|
||||
];
|
||||
async function regenerate(force: boolean) {
|
||||
generating = true;
|
||||
generationError = false;
|
||||
try {
|
||||
await generateRecommendations({ locale: $locale ?? "en", force });
|
||||
await recommendations.refresh();
|
||||
await pollPendingRecommendations();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
generationError = true;
|
||||
} finally {
|
||||
generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeOnBackdrop(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) selected = undefined;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const current = await recommendations;
|
||||
if (current.menus.length === 0) await regenerate(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
generationError = true;
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4 p-3">
|
||||
<section class="rounded-3xl border bg-card p-4 shadow-sm">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-2">
|
||||
<div class="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
||||
<BookImageIcon class="size-3.5" />
|
||||
Fridge guide concept
|
||||
<div class="flex min-h-full flex-col bg-background">
|
||||
<header class="border-b bg-card px-4 py-5">
|
||||
<div class="mx-auto flex max-w-4xl items-start justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2 text-primary">
|
||||
<ChefHatIcon class="size-5" />
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.2em]">
|
||||
{$_("guide.eyebrow")}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Store smarter, waste less</h1>
|
||||
<p class="max-w-[28rem] text-sm text-muted-foreground">
|
||||
Quick visual cheatsheet for where food goes, how long it lasts,
|
||||
and what usually goes wrong.
|
||||
</p>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{$_("guide.title")}</h1>
|
||||
<p class="max-w-xl text-sm text-muted-foreground">
|
||||
{$_("guide.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title={$_("guide.refresh")}
|
||||
disabled={generating}
|
||||
onclick={() => regenerate(true)}
|
||||
>
|
||||
<RefreshCwIcon class={["size-4", generating && "animate-spin"]} />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-7 p-4">
|
||||
{#if (generating || recommendations.current?.pending) && !recommendations.current?.menus.length}
|
||||
<div class="grid flex-1 place-items-center py-20 text-center">
|
||||
<div class="flex max-w-xs flex-col items-center gap-3">
|
||||
<div class="rounded-full bg-primary/10 p-4 text-primary">
|
||||
<LoaderCircleIcon class="size-7 animate-spin" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">{$_("guide.generating")}</p>
|
||||
<p class="text-sm text-muted-foreground">{$_("guide.generating_note")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-24 place-items-center rounded-2xl bg-primary p-3 text-primary-foreground">
|
||||
<SnowflakeIcon class="size-7" />
|
||||
{:else if generationError || recommendations.error}
|
||||
<div class="rounded-2xl border border-destructive/30 bg-destructive/5 p-5">
|
||||
<p class="font-medium text-destructive">{$_("guide.generation_failed")}</p>
|
||||
<Button class="mt-3" variant="outline" onclick={() => regenerate(true)}>
|
||||
{$_("button.try_again")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
{#each tiers as tier (tier.id)}
|
||||
{@const menus = (recommendations.current?.menus ?? []).filter(
|
||||
(menu) => menu.tier === tier.id,
|
||||
)}
|
||||
{#if menus.length}
|
||||
<section class="space-y-3">
|
||||
<div class="px-1">
|
||||
<h2 class="text-lg font-semibold">{$_(tier.label)}</h2>
|
||||
<p class="text-sm text-muted-foreground">{$_(tier.note)}</p>
|
||||
</div>
|
||||
|
||||
<section class="space-y-2">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<h2 class="text-sm font-semibold">Quick guides</h2>
|
||||
<span class="text-xs text-muted-foreground">2-col concept</span>
|
||||
</div>
|
||||
<div class="columns-1 gap-3 min-[520px]:columns-2">
|
||||
{#each menus as menu (menu.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="mb-3 w-full break-inside-avoid overflow-hidden rounded-2xl border bg-card text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-md"
|
||||
onclick={() => (selected = menu)}
|
||||
>
|
||||
{#if menu.imageUrl}
|
||||
<div class="aspect-[16/9] overflow-hidden bg-muted">
|
||||
<Image
|
||||
src={menu.imageUrl}
|
||||
alt=""
|
||||
class="size-full object-cover transition-transform duration-300 hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="space-y-2 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h3 class="font-semibold leading-tight">{menu.title}</h3>
|
||||
<Badge variant="secondary" class="shrink-0 gap-1">
|
||||
<Clock3Icon class="size-3" /> {menu.cookMinutes}m
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="line-clamp-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{menu.description}
|
||||
</p>
|
||||
<div class="flex items-center gap-1 text-xs font-medium text-primary">
|
||||
<SparklesIcon class="size-3.5" />
|
||||
{$_("guide.view_recipe")}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each quickGuides as guide (guide.title)}
|
||||
{#if selected}
|
||||
<div
|
||||
class="fixed inset-0 z-50 overflow-y-auto bg-black/65 p-3 backdrop-blur-sm sm:p-6"
|
||||
role="presentation"
|
||||
onclick={closeOnBackdrop}
|
||||
>
|
||||
<article class="mx-auto max-w-2xl overflow-hidden rounded-3xl bg-background shadow-2xl">
|
||||
<div class="relative aspect-[16/9] bg-linear-to-br from-primary/25 via-muted to-accent">
|
||||
{#if selected.imageUrl}
|
||||
<Image src={selected.imageUrl} alt="" class="size-full object-cover" />
|
||||
{:else}
|
||||
<div class="grid size-full place-items-center text-primary/60">
|
||||
<ChefHatIcon class="size-16" />
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-h-38 flex-col rounded-2xl border bg-card p-3 text-left transition-colors hover:bg-accent"
|
||||
class="absolute right-3 top-3 rounded-full bg-background/90 p-2 shadow backdrop-blur"
|
||||
aria-label={$_("button.close")}
|
||||
onclick={() => (selected = undefined)}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class={[
|
||||
"inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-medium",
|
||||
guide.accent
|
||||
]}>
|
||||
{guide.time}
|
||||
</div>
|
||||
<ChevronRightIcon class="mt-0.5 size-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-1">
|
||||
<h3 class="text-sm font-semibold leading-tight">{guide.title}</h3>
|
||||
<p class="text-xs leading-relaxed text-muted-foreground">
|
||||
{guide.note}
|
||||
</p>
|
||||
</div>
|
||||
<XIcon class="size-5" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="rounded-3xl border bg-card p-4">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<SparklesIcon class="size-4 text-primary" />
|
||||
<h2 class="text-sm font-semibold">Fridge zones</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each zones as zone (zone.title)}
|
||||
<div class="rounded-2xl bg-muted/50 p-3">
|
||||
<div class="mb-1 flex items-center justify-between gap-2">
|
||||
<h3 class="text-sm font-medium">{zone.title}</h3>
|
||||
<span class="text-[11px] text-muted-foreground">{zone.tone}</span>
|
||||
<div class="space-y-7 p-5 sm:p-7">
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge>{$_(`guide.tier.${selected.tier}`)}</Badge>
|
||||
<Badge variant="outline" class="gap-1">
|
||||
<Clock3Icon class="size-3" />
|
||||
{$_("guide.minutes", { values: { count: selected.cookMinutes } })}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-muted-foreground">{zone.items}</p>
|
||||
<h2 class="text-2xl font-semibold tracking-tight">{selected.title}</h2>
|
||||
<p class="leading-relaxed text-muted-foreground">{selected.description}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-amber-500/30 bg-amber-500/8 p-4">
|
||||
<div class="mb-3 flex items-center gap-2 text-amber-700 dark:text-amber-300">
|
||||
<TriangleAlertIcon class="size-4" />
|
||||
<h2 class="text-sm font-semibold">Common mistakes</h2>
|
||||
</div>
|
||||
<section class="space-y-3">
|
||||
<h3 class="text-lg font-semibold">{$_("guide.ingredients")}</h3>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
{#each selected.ingredients as ingredient, i (i)}
|
||||
<div class="flex items-center gap-3 rounded-xl border bg-card p-2.5">
|
||||
<div
|
||||
class="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted"
|
||||
>
|
||||
{#if ingredient.fridgeItem?.icon}
|
||||
<Image
|
||||
src={toImageUrl(ingredient.fridgeItem.icon)}
|
||||
alt=""
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<ImageOffIcon class="size-4 text-muted-foreground" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{ingredient.fridgeItem?.name ?? ingredient.externalName}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">{ingredient.amount}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each warnings as warning (warning)}
|
||||
<div class="flex items-start gap-2 rounded-2xl bg-background/70 px-3 py-2">
|
||||
<Clock3Icon class="mt-0.5 size-4 shrink-0 text-amber-700 dark:text-amber-300" />
|
||||
<p class="text-sm leading-relaxed">{warning}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section class="space-y-3">
|
||||
<h3 class="text-lg font-semibold">{$_("guide.instructions")}</h3>
|
||||
<ol class="space-y-3">
|
||||
{#each selected.instructions as instruction, i (i)}
|
||||
<li class="flex gap-3">
|
||||
<span
|
||||
class="grid size-7 shrink-0 place-items-center rounded-full bg-primary text-xs font-semibold text-primary-foreground"
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<p class="pt-0.5 text-sm leading-relaxed">{instruction}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
|
||||
import ImagePlusIcon from "@lucide/svelte/icons/image-plus";
|
||||
import LoaderCircleIcon from "@lucide/svelte/icons/loader-circle";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { _, locale } from "svelte-i18n";
|
||||
|
||||
type Draft = FoodSuggestion & {
|
||||
include: boolean;
|
||||
@@ -126,8 +126,9 @@
|
||||
|
||||
status = "saving";
|
||||
try {
|
||||
await addItems(
|
||||
included.map((d) => ({
|
||||
await addItems({
|
||||
locale: $locale ?? "en",
|
||||
items: included.map((d) => ({
|
||||
name: d.name,
|
||||
category: d.category,
|
||||
expiry: new Date(
|
||||
@@ -139,7 +140,7 @@
|
||||
size: d.iconBytes.byteLength,
|
||||
},
|
||||
})),
|
||||
);
|
||||
});
|
||||
await getItems().refresh();
|
||||
goto(resolve("/app/list"));
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
WorkflowEntrypoint,
|
||||
type WorkflowEvent,
|
||||
type WorkflowStep,
|
||||
} from "cloudflare:workers";
|
||||
|
||||
type Env = {
|
||||
GUIDE_JOB_URL: string;
|
||||
GUIDE_JOB_SECRET: string;
|
||||
};
|
||||
|
||||
export class RecommendationWorkflow extends WorkflowEntrypoint<Env> {
|
||||
async run(_event: WorkflowEvent<unknown>, step: WorkflowStep) {
|
||||
return step.do(
|
||||
"generate due recommendations",
|
||||
{
|
||||
retries: { limit: 3, delay: "2 minutes", backoff: "exponential" },
|
||||
timeout: "10 minutes",
|
||||
},
|
||||
async () => {
|
||||
const response = await fetch(this.env.GUIDE_JOB_URL, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${this.env.GUIDE_JOB_SECRET}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Recommendation endpoint failed (${response.status})`);
|
||||
}
|
||||
return response.json<{ processed: number; completed: number }>();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
fetch() {
|
||||
return Response.json({ service: "hotfridge-recommendation-workflow" });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "hotfridge-recommendations",
|
||||
"main": "workers/recommendation-workflow.ts",
|
||||
"compatibility_date": "2026-08-24",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"vars": {
|
||||
"GUIDE_JOB_URL": "https://hotfridge.pages.dev/api/recommendations/run"
|
||||
},
|
||||
"workflows": [
|
||||
{
|
||||
"name": "hotfridge-recommendations",
|
||||
"binding": "RECOMMENDATION_WORKFLOW",
|
||||
"class_name": "RecommendationWorkflow",
|
||||
"schedules": ["*/5 * * * *"]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user