From 5d65a5ffaec6309a6bf17ea7f08d25d71eee6cf2 Mon Sep 17 00:00:00 2001 From: Chaewon Lee Date: Fri, 12 Jun 2026 16:19:36 +0900 Subject: [PATCH] feat: use Supabase for flower job storage --- .env.example | 6 ++ package-lock.json | 98 ++++++++++++++++- package.json | 1 + src/lib/flowerFlow/api.js | 3 +- src/lib/server/dev/seedJob.js | 9 +- src/lib/server/flowerFlow/imageStorage.js | 75 +++++++++++++ src/lib/server/flowerFlow/jobStore.js | 100 +++++++++++++++--- src/lib/server/http.js | 4 +- src/lib/server/supabase.js | 49 +++++++++ src/routes/api/dev/seed-flow/+server.js | 12 +-- src/routes/api/dev/skip-images/+server.js | 7 +- .../flower-flow/generate-images/+server.js | 8 +- src/routes/api/flower-flow/job/+server.js | 2 +- .../api/flower-flow/mood-analysis/+server.js | 4 +- src/routes/api/flower-flow/recipe/+server.js | 8 +- .../api/flower-flow/select-option/+server.js | 4 +- src/routes/generating/+page.svelte | 7 +- supabase/schema.sql | 22 ++++ 18 files changed, 364 insertions(+), 55 deletions(-) create mode 100644 src/lib/server/flowerFlow/imageStorage.js create mode 100644 src/lib/server/supabase.js create mode 100644 supabase/schema.sql diff --git a/.env.example b/.env.example index 1654657..4c7cbfc 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,7 @@ GEMINI_TEXT_MODEL=gemini-2.5-flash-lite # mock = instant placeholder images, zero API calls (develop without burning quota) IMAGE_PROVIDER=openai OPENAI_API_KEY=your_openai_api_key_here +OPENAI_IMAGE_MODEL=gpt-image-1 OPENAI_IMAGE_SIZE=1024x1024 GEMINI_IMAGE_MODEL=gemini-3.1-flash-image @@ -16,6 +17,11 @@ KAKAO_REST_API_KEY= # Kakao Maps JavaScript key (map display on /map — public, client-side) PUBLIC_KAKAO_MAP_KEY= +# Supabase (server-side only) +SUPABASE_URL= +SUPABASE_SERVICE_ROLE_KEY= +SUPABASE_STORAGE_BUCKET=flower-bouquets + # Dev seed button: shown only when `npm run dev` (production build hides it). # To mute during local dev, set DEV_SEED_MUTED = true in DevSeedButton.svelte. # Replace static/dev/bouquet-{s,m,l}.jpg with real photos for richer UI previews. diff --git a/package-lock.json b/package-lock.json index 544ff97..6eae3f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@google/generative-ai": "^0.24.1", + "@supabase/supabase-js": "^2.108.1", "openai": "^6.42.0" }, "devDependencies": { @@ -646,6 +647,90 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.108.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.1.tgz", + "integrity": "sha512-Lle5rKU8f9LF3K5dDd8Or8mkkG+ptzRZZWKPVMm9B9UuovH65Ss2+iFnQqRsCqaGouvJEcTWyl0cj2riNrrDLQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.108.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.1.tgz", + "integrity": "sha512-fxBRW/A4IG7ADQztVt0NaEy5ysiO1WJ2pbldsnBchrkHuyepX0Krek9qA9T4gUQBVVTCE9Ea4pdsM5hfn3nc4A==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz", + "integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.108.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.1.tgz", + "integrity": "sha512-9lj2MCPPMgSTaJ5y+amnhb3TWPtMFVlbDn2hmX/VV91xQU4j0AauwfMaBErHBJ+zzsSwjc0jLU+zLIZFLQzfig==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.108.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.1.tgz", + "integrity": "sha512-mHGGqOjwd1XTydcoffUqEMsbFQHUi6A3uhQ0EXr3iqzpLqItxKA9nbN6gIQxrZ7JRRnuUe/iOFPUkYV9Tdc5lg==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.2", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.108.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.1.tgz", + "integrity": "sha512-Er0SGGt85iT6ye+SSh98Az6L2CesoZJuyzEZYH2oBOAnIxa9Nn4CtwUC3veGxYggoT56X+3tVuuQeDBP8kR8sg==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.108.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.1.tgz", + "integrity": "sha512-V/1hRKLSCJ0zEL+9QFRBUtivvePfOsaAYQmC0HhFNSHC2F3xFs4jSF3YhkLmzex6E4V4FGvmBDOP72D/53NnZA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.108.1", + "@supabase/functions-js": "2.108.1", + "@supabase/postgrest-js": "2.108.1", + "@supabase/realtime-js": "2.108.1", + "@supabase/storage-js": "2.108.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", @@ -1644,6 +1729,15 @@ "dev": true, "license": "ISC" }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2782,9 +2876,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", diff --git a/package.json b/package.json index 517f28b..ef37c50 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@google/generative-ai": "^0.24.1", + "@supabase/supabase-js": "^2.108.1", "openai": "^6.42.0" } } diff --git a/src/lib/flowerFlow/api.js b/src/lib/flowerFlow/api.js index e679f18..bc60054 100644 --- a/src/lib/flowerFlow/api.js +++ b/src/lib/flowerFlow/api.js @@ -107,9 +107,10 @@ export async function fetchJob(jobId) { } /** - * @param {{ mimeType?: string, base64?: string } | null | undefined} image + * @param {{ mimeType?: string, base64?: string, url?: string } | null | undefined} image */ export function toDataUrl(image) { + if (image?.url) return image.url; if (!image?.base64) return ''; return `data:${image.mimeType || 'image/png'};base64,${image.base64}`; } diff --git a/src/lib/server/dev/seedJob.js b/src/lib/server/dev/seedJob.js index 37a297c..fd12314 100644 --- a/src/lib/server/dev/seedJob.js +++ b/src/lib/server/dev/seedJob.js @@ -5,6 +5,7 @@ import { mockMoodAnalysis, mockRecipe } from '$lib/server/gemini/mock.js'; +import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js'; import { loadDevBouquetImages } from './loadFixtureImages.js'; /** @typedef {'options' | 'result'} DevSeedStage */ @@ -14,15 +15,15 @@ import { loadDevBouquetImages } from './loadFixtureImages.js'; * @param {Record} userInput * @param {DevSeedStage} [stage='result'] */ -export function seedDevJob(userInput, stage = 'result') { +export async function seedDevJob(userInput, stage = 'result') { const moodAnalysis = mockMoodAnalysis(); const recipe = mockRecipe(userInput); const imagePrompt = mockImagePrompt(recipe); - const images = loadDevBouquetImages(); const floristNote = stage === 'result' ? mockFloristNote(recipe) : null; - const job = createJob(userInput); - updateJob(job.id, { + const job = await createJob(userInput); + const images = await uploadGeneratedImages(job.id, loadDevBouquetImages()); + await updateJob(job.id, { moodAnalysis, recipe, imagePrompt, diff --git a/src/lib/server/flowerFlow/imageStorage.js b/src/lib/server/flowerFlow/imageStorage.js new file mode 100644 index 0000000..f593112 --- /dev/null +++ b/src/lib/server/flowerFlow/imageStorage.js @@ -0,0 +1,75 @@ +import { + getSupabaseClient, + getSupabaseStorageBucket, + throwSupabaseError +} from '$lib/server/supabase.js'; + +/** @typedef {import('./jobStore.js').BouquetSize} BouquetSize */ +/** @typedef {import('./jobStore.js').GeneratedImage} GeneratedImage */ + +const EXTENSION_BY_MIME = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/svg+xml': 'svg' +}; + +/** @param {string} mimeType */ +function extensionForMime(mimeType) { + return EXTENSION_BY_MIME[mimeType] ?? 'bin'; +} + +/** + * @param {string} jobId + * @param {BouquetSize} size + * @param {GeneratedImage} image + * @returns {Promise} + */ +export async function uploadGeneratedImage(jobId, size, image) { + if (!image.base64) { + return image; + } + + const supabase = getSupabaseClient(); + const bucket = getSupabaseStorageBucket(); + const mimeType = image.mimeType || 'image/png'; + const path = `${jobId}/${size.toLowerCase()}.${extensionForMime(mimeType)}`; + const bytes = Buffer.from(image.base64, 'base64'); + + const { error } = await supabase.storage.from(bucket).upload(path, bytes, { + contentType: mimeType, + upsert: true + }); + + if (error) { + throwSupabaseError(error, 'upload bouquet image'); + } + + const { data } = supabase.storage.from(bucket).getPublicUrl(path); + + return { + mimeType, + url: data.publicUrl, + path + }; +} + +/** + * @param {string} jobId + * @param {Partial>} images + * @returns {Promise>>} + */ +export async function uploadGeneratedImages(jobId, images) { + /** @type {Partial>} */ + const uploaded = {}; + const sizes = /** @type {BouquetSize[]} */ (['S', 'M', 'L']); + + for (const size of sizes) { + const image = images[size]; + if (image) { + uploaded[size] = await uploadGeneratedImage(jobId, size, image); + } + } + + return uploaded; +} diff --git a/src/lib/server/flowerFlow/jobStore.js b/src/lib/server/flowerFlow/jobStore.js index bd499bc..731ff5f 100644 --- a/src/lib/server/flowerFlow/jobStore.js +++ b/src/lib/server/flowerFlow/jobStore.js @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { getSupabaseClient, throwSupabaseError } from '$lib/server/supabase.js'; /** @typedef {'S' | 'M' | 'L'} BouquetSize */ @@ -36,7 +37,9 @@ import { randomUUID } from 'node:crypto'; /** * @typedef {Object} GeneratedImage * @property {string} mimeType - * @property {string} base64 + * @property {string} [base64] + * @property {string} [url] + * @property {string} [path] */ /** @@ -52,16 +55,51 @@ import { randomUUID } from 'node:crypto'; * @property {string | null} floristNote */ -/** @type {Map} */ -const jobs = new Map(); +/** + * @param {any} row + * @returns {FlowerJob} + */ +function fromRow(row) { + return { + id: row.id, + createdAt: new Date(row.created_at).getTime(), + userInput: row.user_input ?? {}, + moodAnalysis: row.mood_analysis ?? null, + recipe: row.recipe ?? null, + imagePrompt: row.image_prompt ?? null, + images: row.images ?? {}, + selectedSize: row.selected_size ?? null, + floristNote: row.florist_note ?? null + }; +} + +/** + * @param {Partial} patch + */ +function toRowPatch(patch) { + /** @type {Record} */ + const row = {}; + + if ('userInput' in patch) row.user_input = patch.userInput ?? {}; + if ('moodAnalysis' in patch) row.mood_analysis = patch.moodAnalysis; + if ('recipe' in patch) row.recipe = patch.recipe; + if ('imagePrompt' in patch) row.image_prompt = patch.imagePrompt; + if ('images' in patch) row.images = patch.images ?? {}; + if ('selectedSize' in patch) row.selected_size = patch.selectedSize; + if ('floristNote' in patch) row.florist_note = patch.floristNote; + + return row; +} /** @param {Partial} [userInput] */ -export function createJob(userInput = {}) { +export async function createJob(userInput = {}) { const id = randomUUID(); + const createdAt = Date.now(); + /** @type {FlowerJob} */ const job = { id, - createdAt: Date.now(), + createdAt, userInput, moodAnalysis: null, recipe: null, @@ -71,31 +109,61 @@ export function createJob(userInput = {}) { floristNote: null }; - jobs.set(id, job); + const { error } = await getSupabaseClient() + .from('flower_jobs') + .insert({ + id, + created_at: new Date(createdAt).toISOString(), + user_input: userInput, + images: {} + }); + + if (error) { + throwSupabaseError(error, 'insert flower job'); + } + return job; } /** @param {string} jobId */ -export function getJob(jobId) { - return jobs.get(jobId) ?? null; +export async function getJob(jobId) { + const { data, error } = await getSupabaseClient() + .from('flower_jobs') + .select('*') + .eq('id', jobId) + .maybeSingle(); + + if (error) { + throwSupabaseError(error, 'select flower job'); + } + + return data ? fromRow(data) : null; } /** * @param {string} jobId * @param {Partial} patch */ -export function updateJob(jobId, patch) { - const job = jobs.get(jobId); - if (!job) return null; +export async function updateJob(jobId, patch) { + const rowPatch = toRowPatch(patch); - const updated = { ...job, ...patch }; - jobs.set(jobId, updated); - return updated; + const { data, error } = await getSupabaseClient() + .from('flower_jobs') + .update(rowPatch) + .eq('id', jobId) + .select('*') + .maybeSingle(); + + if (error) { + throwSupabaseError(error, 'update flower job'); + } + + return data ? fromRow(data) : null; } /** @param {string} jobId */ -export function requireJob(jobId) { - const job = getJob(jobId); +export async function requireJob(jobId) { + const job = await getJob(jobId); if (!job) { throw new JobNotFoundError(jobId); } diff --git a/src/lib/server/http.js b/src/lib/server/http.js index b675dbe..eebb264 100644 --- a/src/lib/server/http.js +++ b/src/lib/server/http.js @@ -6,9 +6,7 @@ import { describeAiError } from '$lib/server/aiError.js'; */ export function toErrorResponse(error) { if (error instanceof JobNotFoundError) { - console.warn( - `[flower-flow] job_not_found (404) — ${error.message} (server restart wipes jobs)` - ); + console.warn(`[flower-flow] job_not_found (404) — ${error.message}`); return new Response(JSON.stringify({ error: error.message, code: 'job_not_found' }), { status: 404, headers: { 'Content-Type': 'application/json' } diff --git a/src/lib/server/supabase.js b/src/lib/server/supabase.js new file mode 100644 index 0000000..7c5b5b1 --- /dev/null +++ b/src/lib/server/supabase.js @@ -0,0 +1,49 @@ +import { env } from '$env/dynamic/private'; +import { createClient } from '@supabase/supabase-js'; + +let client = null; + +export function getSupabaseClient() { + if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) { + throw new Error('SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be configured'); + } + + if (!client) { + client = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, { + auth: { + persistSession: false, + autoRefreshToken: false + } + }); + } + + return client; +} + +export function getSupabaseStorageBucket() { + return env.SUPABASE_STORAGE_BUCKET || 'flower-bouquets'; +} + +/** + * @param {unknown} error + * @param {string} action + * @returns {never} + */ +export function throwSupabaseError(error, action) { + /** @type {any} */ + const supabaseError = error; + const message = + typeof supabaseError?.message === 'string' + ? supabaseError.message + : 'Unexpected Supabase error'; + const detail = typeof supabaseError?.details === 'string' ? ` ${supabaseError.details}` : ''; + const hint = typeof supabaseError?.hint === 'string' ? ` Hint: ${supabaseError.hint}` : ''; + const wrapped = new Error(`Supabase ${action} failed: ${message}.${detail}${hint}`); + + wrapped.name = 'SupabaseError'; + // Preserve the PostgREST/storage code for server logs and future classifiers. + // @ts-expect-error - attach provider-specific metadata to a standard Error. + wrapped.code = supabaseError?.code; + + throw wrapped; +} diff --git a/src/routes/api/dev/seed-flow/+server.js b/src/routes/api/dev/seed-flow/+server.js index 33459a2..99ab69f 100644 --- a/src/routes/api/dev/seed-flow/+server.js +++ b/src/routes/api/dev/seed-flow/+server.js @@ -1,10 +1,6 @@ import { dev } from '$app/environment'; import { json } from '@sveltejs/kit'; -import { - DEV_CARD_MESSAGE, - DEV_USER_INPUT, - DEV_USER_INPUT_WITH_NOTES -} from '$lib/dev/fixtures.js'; +import { DEV_CARD_MESSAGE, DEV_USER_INPUT, DEV_USER_INPUT_WITH_NOTES } from '$lib/dev/fixtures.js'; import { DEV_MOODBOARD_UPLOAD, DEV_SNS_UPLOAD } from '$lib/dev/uploadFixtures.js'; import { seedDevJob } from '$lib/server/dev/seedJob.js'; @@ -24,7 +20,7 @@ export async function POST({ request }) { // 기본값 사용 } - const seeded = seedDevJob(DEV_USER_INPUT_WITH_NOTES, stage); + const seeded = await seedDevJob(DEV_USER_INPUT_WITH_NOTES, stage); return json({ stage, @@ -47,9 +43,7 @@ export async function POST({ request }) { moodboard: DEV_MOODBOARD_UPLOAD, sns: DEV_SNS_UPLOAD }, - ...(stage === 'result' - ? { selectedSize: 'M', floristNote: seeded.floristNote } - : {}) + ...(stage === 'result' ? { selectedSize: 'M', floristNote: seeded.floristNote } : {}) }, // create 폼 초기값 참고용 (relationship/occasion/style/budget만) formDefaults: DEV_USER_INPUT diff --git a/src/routes/api/dev/skip-images/+server.js b/src/routes/api/dev/skip-images/+server.js index ef250bb..b57af86 100644 --- a/src/routes/api/dev/skip-images/+server.js +++ b/src/routes/api/dev/skip-images/+server.js @@ -2,6 +2,7 @@ import { dev } from '$app/environment'; import { json } from '@sveltejs/kit'; import { loadDevBouquetImages } from '$lib/server/dev/loadFixtureImages.js'; import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js'; +import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js'; import { mockImagePrompt, mockMoodAnalysis, mockRecipe } from '$lib/server/gemini/mock.js'; import { readJsonBody } from '$lib/server/http.js'; @@ -19,13 +20,13 @@ export async function POST({ request }) { return json({ error: 'jobId is required' }, 400); } - const job = requireJob(jobId); + const job = await requireJob(jobId); const moodAnalysis = job.moodAnalysis ?? mockMoodAnalysis(); const recipe = job.recipe ?? mockRecipe(job.userInput); const imagePrompt = job.imagePrompt ?? mockImagePrompt(recipe); - const images = loadDevBouquetImages(); + const images = await uploadGeneratedImages(jobId, loadDevBouquetImages()); - updateJob(jobId, { moodAnalysis, recipe, imagePrompt, images }); + await updateJob(jobId, { moodAnalysis, recipe, imagePrompt, images }); return json({ jobId, diff --git a/src/routes/api/flower-flow/generate-images/+server.js b/src/routes/api/flower-flow/generate-images/+server.js index 2a7bcfd..7726748 100644 --- a/src/routes/api/flower-flow/generate-images/+server.js +++ b/src/routes/api/flower-flow/generate-images/+server.js @@ -5,6 +5,7 @@ import { getImageProvider, isImageGenerationConfigured } from '$lib/server/gemini/image.js'; +import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js'; import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js'; /** @@ -29,8 +30,9 @@ function generateForJob(jobId, recipe) { const task = (async () => { const imagePrompt = await buildImagePrompt(recipe); - const images = await generateAllSizeImages(imagePrompt); - updateJob(jobId, { imagePrompt, images }); + const generatedImages = await generateAllSizeImages(imagePrompt); + const images = await uploadGeneratedImages(jobId, generatedImages); + await updateJob(jobId, { imagePrompt, images }); return { imagePrompt, images }; })().finally(() => { inFlight.delete(jobId); @@ -50,7 +52,7 @@ export async function POST({ request }) { return json({ error: 'jobId is required', code: 'bad_request' }, 400); } - const job = requireJob(jobId); + const job = await requireJob(jobId); if (!job.recipe) { return json({ error: 'recipe is missing. Run recipe first.', code: 'bad_request' }, 400); diff --git a/src/routes/api/flower-flow/job/+server.js b/src/routes/api/flower-flow/job/+server.js index fd23509..6decd00 100644 --- a/src/routes/api/flower-flow/job/+server.js +++ b/src/routes/api/flower-flow/job/+server.js @@ -11,7 +11,7 @@ export async function GET({ url }) { return json({ error: 'jobId is required' }, 400); } - const job = requireJob(jobId); + const job = await requireJob(jobId); return json({ jobId: job.id, diff --git a/src/routes/api/flower-flow/mood-analysis/+server.js b/src/routes/api/flower-flow/mood-analysis/+server.js index d5f05c4..fbfbc91 100644 --- a/src/routes/api/flower-flow/mood-analysis/+server.js +++ b/src/routes/api/flower-flow/mood-analysis/+server.js @@ -14,11 +14,11 @@ export async function POST({ request }) { } const userInput = readUserInput(formData); - const job = createJob(userInput); + const job = await createJob(userInput); const imageBytes = new Uint8Array(await image.arrayBuffer()); const moodAnalysis = await analyzeImageMood(imageBytes, image.type || 'image/jpeg', userInput); - updateJob(job.id, { moodAnalysis }); + await updateJob(job.id, { moodAnalysis }); return json({ jobId: job.id, diff --git a/src/routes/api/flower-flow/recipe/+server.js b/src/routes/api/flower-flow/recipe/+server.js index 26b8bc5..105c68d 100644 --- a/src/routes/api/flower-flow/recipe/+server.js +++ b/src/routes/api/flower-flow/recipe/+server.js @@ -13,21 +13,21 @@ export async function POST({ request }) { return json({ error: 'jobId is required' }, 400); } - const job = requireJob(jobId); + const job = await requireJob(jobId); if (!job.moodAnalysis) { return json({ error: 'moodAnalysis is missing. Run mood-analysis first.' }, 400); } if (body.userInput && typeof body.userInput === 'object') { - updateJob(jobId, { + await updateJob(jobId, { userInput: { ...job.userInput, .../** @type {Record} */ (body.userInput) } }); } - const currentJob = requireJob(jobId); + const currentJob = await requireJob(jobId); const recipe = await buildBouquetRecipe(currentJob.moodAnalysis, currentJob.userInput); - updateJob(jobId, { recipe }); + await updateJob(jobId, { recipe }); return json({ jobId, diff --git a/src/routes/api/flower-flow/select-option/+server.js b/src/routes/api/flower-flow/select-option/+server.js index 101783f..5259c84 100644 --- a/src/routes/api/flower-flow/select-option/+server.js +++ b/src/routes/api/flower-flow/select-option/+server.js @@ -21,7 +21,7 @@ export async function POST({ request }) { return json({ error: 'size must be one of S, M, or L' }, 400); } - const job = requireJob(jobId); + const job = await requireJob(jobId); const selectedImage = job.images?.[/** @type {'S'|'M'|'L'} */ (size)]; if (!selectedImage) { @@ -30,7 +30,7 @@ export async function POST({ request }) { const floristNote = job.recipe ? await buildFloristNote(job.recipe) : null; - updateJob(jobId, { + await updateJob(jobId, { selectedSize: /** @type {'S'|'M'|'L'} */ (size), floristNote }); diff --git a/src/routes/generating/+page.svelte b/src/routes/generating/+page.svelte index 7be995e..49d4ede 100644 --- a/src/routes/generating/+page.svelte +++ b/src/routes/generating/+page.svelte @@ -101,8 +101,8 @@ ); // Do NOT persist the multi-MB base64 images in sessionStorage — Safari caps // it at ~5MB and throws "QuotaExceededError: The quota has been exceeded." - // The images already live server-side in the job; the options and result - // pages fetch them by jobId. We only keep lightweight metadata here. + // The images already live in Supabase Storage via the job; the options + // and result pages fetch them by jobId. We only keep lightweight metadata here. saveFlow({ imagesJobId: jobId, imagePrompt: imageResult.imagePrompt, @@ -113,8 +113,7 @@ } catch (err) { if (!active) return; - // The server lost this job (e.g. a dev-server restart wipes the in-memory - // job store). The stored jobId is dead, so retrying is pointless — clear + // The stored jobId no longer resolves, so retrying is pointless — clear // the stale flow and send the user back to re-upload. const code = err && typeof err === 'object' && 'code' in err ? err.code : ''; const stale = diff --git a/supabase/schema.sql b/supabase/schema.sql new file mode 100644 index 0000000..55fde20 --- /dev/null +++ b/supabase/schema.sql @@ -0,0 +1,22 @@ +create table if not exists public.flower_jobs ( + id uuid primary key, + created_at timestamptz not null default now(), + user_input jsonb not null default '{}'::jsonb, + mood_analysis jsonb, + recipe jsonb, + image_prompt text, + images jsonb not null default '{}'::jsonb, + selected_size text check (selected_size in ('S', 'M', 'L')), + florist_note text +); + +alter table public.flower_jobs enable row level security; + +create index if not exists flower_jobs_created_at_idx on public.flower_jobs (created_at desc); + +grant usage on schema public to service_role; +grant select, insert, update, delete on public.flower_jobs to service_role; + +insert into storage.buckets (id, name, public) +values ('flower-bouquets', 'flower-bouquets', true) +on conflict (id) do update set public = excluded.public;