This commit is contained in:
@@ -18,12 +18,42 @@ jobs:
|
|||||||
- name: Install Dependencies & Build
|
- name: Install Dependencies & Build
|
||||||
run: |
|
run: |
|
||||||
echo "VITE_GEMINI_API_KEY=${{ secrets.VITE_GEMINI_API_KEY }}" >> .env
|
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 i --frozen-lockfile
|
||||||
bun run build
|
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
|
- name: Deploy to Cloudflare Pages
|
||||||
uses: cloudflare/wrangler-action@v3
|
uses: cloudflare/wrangler-action@v3
|
||||||
with:
|
with:
|
||||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
command: pages deploy .svelte-kit/cloudflare --project-name=hotfridge
|
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": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"deploy:workflow": "wrangler deploy --config wrangler.workflow.jsonc",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"lint": "oxlint .",
|
"lint": "oxlint .",
|
||||||
|
|||||||
Vendored
+2
@@ -21,6 +21,8 @@ declare global {
|
|||||||
interface Platform {
|
interface Platform {
|
||||||
env: {
|
env: {
|
||||||
DB: D1Database;
|
DB: D1Database;
|
||||||
|
GEMINI_API_KEY?: string;
|
||||||
|
GUIDE_JOB_SECRET?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-9
@@ -1,14 +1,33 @@
|
|||||||
import { defineRelations } from "drizzle-orm";
|
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) => ({
|
export const relations = defineRelations(
|
||||||
tItems: {
|
{ tItems, tImages, tRecommendationBatches, tRecommendations },
|
||||||
icon: r.one.tImages({
|
(r) => ({
|
||||||
from: r.tItems.iconId,
|
tItems: {
|
||||||
to: r.tImages.id,
|
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 };
|
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 ItemId = number & { __brand: "item_id" };
|
||||||
export type ImageId = number & { __brand: "image_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", {
|
export const tItems = sqliteTable("items", {
|
||||||
id: integer().$type<ItemId>().primaryKey({ autoIncrement: true }),
|
id: integer().$type<ItemId>().primaryKey({ autoIncrement: true }),
|
||||||
@@ -22,5 +26,47 @@ export const tImages = sqliteTable("images", {
|
|||||||
size: integer().notNull(),
|
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 TItem = typeof tItems.$inferSelect;
|
||||||
export type TImage = typeof tImages.$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 { 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 { getDb } from "$lib/server/db";
|
||||||
|
import {
|
||||||
|
getRecommendationRateLimitKey,
|
||||||
|
scheduleRecommendationJob,
|
||||||
|
} from "$lib/server/recommendations";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export const getItems = query(() => {
|
export const getItems = query(() => {
|
||||||
@@ -22,14 +30,20 @@ export const getItem = query("unchecked", (id: ItemId) => {
|
|||||||
export const addItems = command(
|
export const addItems = command(
|
||||||
"unchecked",
|
"unchecked",
|
||||||
async (
|
async (
|
||||||
items: {
|
{
|
||||||
name: string;
|
items,
|
||||||
category: string;
|
locale,
|
||||||
expiry: Date;
|
}: {
|
||||||
icon: { mime: string; data: Uint8Array; size: number };
|
items: {
|
||||||
}[],
|
name: string;
|
||||||
|
category: string;
|
||||||
|
expiry: Date;
|
||||||
|
icon: { mime: string; data: Uint8Array; size: number };
|
||||||
|
}[];
|
||||||
|
locale: string;
|
||||||
|
},
|
||||||
): Promise<ItemId[]> => {
|
): Promise<ItemId[]> => {
|
||||||
const { locals } = getRequestEvent();
|
const { locals, request } = getRequestEvent();
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const ids: ItemId[] = [];
|
const ids: ItemId[] = [];
|
||||||
|
|
||||||
@@ -47,12 +61,18 @@ export const addItems = command(
|
|||||||
ids.push(inserted.id);
|
ids.push(inserted.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await scheduleRecommendationJob(db, locals.sessionId, {
|
||||||
|
locale,
|
||||||
|
delay: 5 * 60 * 1000,
|
||||||
|
rateLimitKey: await getRecommendationRateLimitKey(request),
|
||||||
|
});
|
||||||
|
|
||||||
return ids;
|
return ids;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const removeItem = command("unchecked", async (id: ItemId) => {
|
export const removeItem = command("unchecked", async (id: ItemId) => {
|
||||||
const { locals } = getRequestEvent();
|
const { locals, request } = getRequestEvent();
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const item = await db.query.tItems.findFirst({
|
const item = await db.query.tItems.findFirst({
|
||||||
where: { id: { eq: id }, sessionId: { eq: locals.sessionId } },
|
where: { id: { eq: id }, sessionId: { eq: locals.sessionId } },
|
||||||
@@ -65,4 +85,8 @@ export const removeItem = command("unchecked", async (id: ItemId) => {
|
|||||||
} else {
|
} else {
|
||||||
await db.delete(tItems).where(eq(tItems.id, id));
|
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...",
|
"saving": "Saving...",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"delete": "Delete",
|
"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": {
|
"label": {
|
||||||
"food": "Food",
|
"food": "Food",
|
||||||
|
|||||||
+27
-1
@@ -34,7 +34,33 @@
|
|||||||
"saving": "กำลังบันทึก...",
|
"saving": "กำลังบันทึก...",
|
||||||
"cancel": "ยกเลิก",
|
"cancel": "ยกเลิก",
|
||||||
"delete": "ลบ",
|
"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": {
|
"label": {
|
||||||
"food": "อาหาร",
|
"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 { getRequestEvent } from "$app/server";
|
||||||
import { relations } from "$lib/db/relations";
|
import { relations } from "$lib/db/relations";
|
||||||
|
import type { D1Database } from "@cloudflare/workers-types";
|
||||||
import { drizzle } from "drizzle-orm/d1";
|
import { drizzle } from "drizzle-orm/d1";
|
||||||
|
|
||||||
|
export function createDb(database: D1Database) {
|
||||||
|
return drizzle(database, { relations, logger: true });
|
||||||
|
}
|
||||||
|
|
||||||
export function getDb() {
|
export function getDb() {
|
||||||
const { platform } = getRequestEvent();
|
const { platform } = getRequestEvent();
|
||||||
if (!platform) {
|
if (!platform) {
|
||||||
@@ -9,5 +14,5 @@ export function getDb() {
|
|||||||
"platform.env is unavailable — is the D1 binding configured in wrangler.jsonc?",
|
"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">
|
<script lang="ts">
|
||||||
import BookImageIcon from "@lucide/svelte/icons/book-image";
|
import Image from "$lib/components/image.svelte";
|
||||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
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 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 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",
|
id: "near_expiry_food",
|
||||||
note: "Wrap with paper before chilling.",
|
label: "guide.tier.near_expiry_food",
|
||||||
time: "3-5 days",
|
note: "guide.tier_note.near_expiry_food",
|
||||||
accent: "bg-emerald-500/12 text-emerald-700 dark:text-emerald-300"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Cooked rice",
|
id: "near_expiry_ingredients",
|
||||||
note: "Seal fast while still slightly warm.",
|
label: "guide.tier.near_expiry_ingredients",
|
||||||
time: "1-2 days",
|
note: "guide.tier_note.near_expiry_ingredients",
|
||||||
accent: "bg-amber-500/12 text-amber-700 dark:text-amber-300"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Fresh meat",
|
id: "discovery",
|
||||||
note: "Keep on the coldest shelf, bottom tray.",
|
label: "guide.tier.discovery",
|
||||||
time: "1-2 days",
|
note: "guide.tier_note.discovery",
|
||||||
accent: "bg-rose-500/12 text-rose-700 dark:text-rose-300"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Sauces",
|
id: "fridge",
|
||||||
note: "Door shelf is fine once opened.",
|
label: "guide.tier.fridge",
|
||||||
time: "1 month",
|
note: "guide.tier_note.fridge",
|
||||||
accent: "bg-sky-500/12 text-sky-700 dark:text-sky-300"
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
];
|
if (mounted && current && !current.pending && current.menus.length === 0) {
|
||||||
|
generationError = true;
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
];
|
}
|
||||||
|
|
||||||
const warnings = [
|
async function regenerate(force: boolean) {
|
||||||
"Milk beside the door expires faster.",
|
generating = true;
|
||||||
"Hot food raises the whole fridge temp.",
|
generationError = false;
|
||||||
"Uncovered leftovers dry out and smell spreads.",
|
try {
|
||||||
"Overpacking blocks cold air circulation."
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 p-3">
|
<div class="flex min-h-full flex-col bg-background">
|
||||||
<section class="rounded-3xl border bg-card p-4 shadow-sm">
|
<header class="border-b bg-card px-4 py-5">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="mx-auto flex max-w-4xl items-start justify-between gap-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-1">
|
||||||
<div class="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
<div class="flex items-center gap-2 text-primary">
|
||||||
<BookImageIcon class="size-3.5" />
|
<ChefHatIcon class="size-5" />
|
||||||
Fridge guide concept
|
<span class="text-xs font-semibold uppercase tracking-[0.2em]">
|
||||||
|
{$_("guide.eyebrow")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<h1 class="text-2xl font-semibold tracking-tight">{$_("guide.title")}</h1>
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Store smarter, waste less</h1>
|
<p class="max-w-xl text-sm text-muted-foreground">
|
||||||
<p class="max-w-[28rem] text-sm text-muted-foreground">
|
{$_("guide.description")}
|
||||||
Quick visual cheatsheet for where food goes, how long it lasts,
|
</p>
|
||||||
and what usually goes wrong.
|
</div>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
|
{:else if generationError || recommendations.error}
|
||||||
<div class="grid min-w-24 place-items-center rounded-2xl bg-primary p-3 text-primary-foreground">
|
<div class="rounded-2xl border border-destructive/30 bg-destructive/5 p-5">
|
||||||
<SnowflakeIcon class="size-7" />
|
<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>
|
||||||
</div>
|
{:else}
|
||||||
</section>
|
{#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="columns-1 gap-3 min-[520px]:columns-2">
|
||||||
<div class="flex items-center justify-between px-1">
|
{#each menus as menu (menu.id)}
|
||||||
<h2 class="text-sm font-semibold">Quick guides</h2>
|
<button
|
||||||
<span class="text-xs text-muted-foreground">2-col concept</span>
|
type="button"
|
||||||
</div>
|
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">
|
{#if selected}
|
||||||
{#each quickGuides as guide (guide.title)}
|
<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
|
<button
|
||||||
type="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">
|
<XIcon class="size-5" />
|
||||||
<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>
|
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="rounded-3xl border bg-card p-4">
|
<div class="space-y-7 p-5 sm:p-7">
|
||||||
<div class="mb-3 flex items-center gap-2">
|
<div class="space-y-2">
|
||||||
<SparklesIcon class="size-4 text-primary" />
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<h2 class="text-sm font-semibold">Fridge zones</h2>
|
<Badge>{$_(`guide.tier.${selected.tier}`)}</Badge>
|
||||||
</div>
|
<Badge variant="outline" class="gap-1">
|
||||||
|
<Clock3Icon class="size-3" />
|
||||||
<div class="grid grid-cols-2 gap-2">
|
{$_("guide.minutes", { values: { count: selected.cookMinutes } })}
|
||||||
{#each zones as zone (zone.title)}
|
</Badge>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="rounded-3xl border border-amber-500/30 bg-amber-500/8 p-4">
|
<section class="space-y-3">
|
||||||
<div class="mb-3 flex items-center gap-2 text-amber-700 dark:text-amber-300">
|
<h3 class="text-lg font-semibold">{$_("guide.ingredients")}</h3>
|
||||||
<TriangleAlertIcon class="size-4" />
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
<h2 class="text-sm font-semibold">Common mistakes</h2>
|
{#each selected.ingredients as ingredient, i (i)}
|
||||||
</div>
|
<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">
|
<section class="space-y-3">
|
||||||
{#each warnings as warning (warning)}
|
<h3 class="text-lg font-semibold">{$_("guide.instructions")}</h3>
|
||||||
<div class="flex items-start gap-2 rounded-2xl bg-background/70 px-3 py-2">
|
<ol class="space-y-3">
|
||||||
<Clock3Icon class="mt-0.5 size-4 shrink-0 text-amber-700 dark:text-amber-300" />
|
{#each selected.instructions as instruction, i (i)}
|
||||||
<p class="text-sm leading-relaxed">{warning}</p>
|
<li class="flex gap-3">
|
||||||
</div>
|
<span
|
||||||
{/each}
|
class="grid size-7 shrink-0 place-items-center rounded-full bg-primary text-xs font-semibold text-primary-foreground"
|
||||||
</div>
|
>
|
||||||
</section>
|
{i + 1}
|
||||||
</div>
|
</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 ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
|
||||||
import ImagePlusIcon from "@lucide/svelte/icons/image-plus";
|
import ImagePlusIcon from "@lucide/svelte/icons/image-plus";
|
||||||
import LoaderCircleIcon from "@lucide/svelte/icons/loader-circle";
|
import LoaderCircleIcon from "@lucide/svelte/icons/loader-circle";
|
||||||
import { _ } from "svelte-i18n";
|
import { _, locale } from "svelte-i18n";
|
||||||
|
|
||||||
type Draft = FoodSuggestion & {
|
type Draft = FoodSuggestion & {
|
||||||
include: boolean;
|
include: boolean;
|
||||||
@@ -126,8 +126,9 @@
|
|||||||
|
|
||||||
status = "saving";
|
status = "saving";
|
||||||
try {
|
try {
|
||||||
await addItems(
|
await addItems({
|
||||||
included.map((d) => ({
|
locale: $locale ?? "en",
|
||||||
|
items: included.map((d) => ({
|
||||||
name: d.name,
|
name: d.name,
|
||||||
category: d.category,
|
category: d.category,
|
||||||
expiry: new Date(
|
expiry: new Date(
|
||||||
@@ -139,7 +140,7 @@
|
|||||||
size: d.iconBytes.byteLength,
|
size: d.iconBytes.byteLength,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
);
|
});
|
||||||
await getItems().refresh();
|
await getItems().refresh();
|
||||||
goto(resolve("/app/list"));
|
goto(resolve("/app/list"));
|
||||||
} catch (error) {
|
} 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