feat: use Supabase for flower job storage

This commit is contained in:
Chaewon Lee
2026-06-12 16:19:36 +09:00
committed by GitHub
parent 922320d59a
commit 5d65a5ffae
18 changed files with 364 additions and 55 deletions

View File

@@ -7,6 +7,7 @@ GEMINI_TEXT_MODEL=gemini-2.5-flash-lite
# mock = instant placeholder images, zero API calls (develop without burning quota) # mock = instant placeholder images, zero API calls (develop without burning quota)
IMAGE_PROVIDER=openai IMAGE_PROVIDER=openai
OPENAI_API_KEY=your_openai_api_key_here OPENAI_API_KEY=your_openai_api_key_here
OPENAI_IMAGE_MODEL=gpt-image-1
OPENAI_IMAGE_SIZE=1024x1024 OPENAI_IMAGE_SIZE=1024x1024
GEMINI_IMAGE_MODEL=gemini-3.1-flash-image 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) # Kakao Maps JavaScript key (map display on /map — public, client-side)
PUBLIC_KAKAO_MAP_KEY= 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). # 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. # 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. # Replace static/dev/bouquet-{s,m,l}.jpg with real photos for richer UI previews.

98
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@google/generative-ai": "^0.24.1", "@google/generative-ai": "^0.24.1",
"@supabase/supabase-js": "^2.108.1",
"openai": "^6.42.0" "openai": "^6.42.0"
}, },
"devDependencies": { "devDependencies": {
@@ -646,6 +647,90 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz",
@@ -1644,6 +1729,15 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2782,9 +2876,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "license": "0BSD"
"license": "0BSD",
"optional": true
}, },
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",

View File

@@ -32,6 +32,7 @@
}, },
"dependencies": { "dependencies": {
"@google/generative-ai": "^0.24.1", "@google/generative-ai": "^0.24.1",
"@supabase/supabase-js": "^2.108.1",
"openai": "^6.42.0" "openai": "^6.42.0"
} }
} }

View File

@@ -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) { export function toDataUrl(image) {
if (image?.url) return image.url;
if (!image?.base64) return ''; if (!image?.base64) return '';
return `data:${image.mimeType || 'image/png'};base64,${image.base64}`; return `data:${image.mimeType || 'image/png'};base64,${image.base64}`;
} }

View File

@@ -5,6 +5,7 @@ import {
mockMoodAnalysis, mockMoodAnalysis,
mockRecipe mockRecipe
} from '$lib/server/gemini/mock.js'; } from '$lib/server/gemini/mock.js';
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
import { loadDevBouquetImages } from './loadFixtureImages.js'; import { loadDevBouquetImages } from './loadFixtureImages.js';
/** @typedef {'options' | 'result'} DevSeedStage */ /** @typedef {'options' | 'result'} DevSeedStage */
@@ -14,15 +15,15 @@ import { loadDevBouquetImages } from './loadFixtureImages.js';
* @param {Record<string, unknown>} userInput * @param {Record<string, unknown>} userInput
* @param {DevSeedStage} [stage='result'] * @param {DevSeedStage} [stage='result']
*/ */
export function seedDevJob(userInput, stage = 'result') { export async function seedDevJob(userInput, stage = 'result') {
const moodAnalysis = mockMoodAnalysis(); const moodAnalysis = mockMoodAnalysis();
const recipe = mockRecipe(userInput); const recipe = mockRecipe(userInput);
const imagePrompt = mockImagePrompt(recipe); const imagePrompt = mockImagePrompt(recipe);
const images = loadDevBouquetImages();
const floristNote = stage === 'result' ? mockFloristNote(recipe) : null; const floristNote = stage === 'result' ? mockFloristNote(recipe) : null;
const job = createJob(userInput); const job = await createJob(userInput);
updateJob(job.id, { const images = await uploadGeneratedImages(job.id, loadDevBouquetImages());
await updateJob(job.id, {
moodAnalysis, moodAnalysis,
recipe, recipe,
imagePrompt, imagePrompt,

View File

@@ -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<GeneratedImage>}
*/
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<Record<BouquetSize, GeneratedImage>>} images
* @returns {Promise<Partial<Record<BouquetSize, GeneratedImage>>>}
*/
export async function uploadGeneratedImages(jobId, images) {
/** @type {Partial<Record<BouquetSize, GeneratedImage>>} */
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;
}

View File

@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { getSupabaseClient, throwSupabaseError } from '$lib/server/supabase.js';
/** @typedef {'S' | 'M' | 'L'} BouquetSize */ /** @typedef {'S' | 'M' | 'L'} BouquetSize */
@@ -36,7 +37,9 @@ import { randomUUID } from 'node:crypto';
/** /**
* @typedef {Object} GeneratedImage * @typedef {Object} GeneratedImage
* @property {string} mimeType * @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 * @property {string | null} floristNote
*/ */
/** @type {Map<string, FlowerJob>} */ /**
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<FlowerJob>} patch
*/
function toRowPatch(patch) {
/** @type {Record<string, unknown>} */
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>} [userInput] */ /** @param {Partial<UserInput>} [userInput] */
export function createJob(userInput = {}) { export async function createJob(userInput = {}) {
const id = randomUUID(); const id = randomUUID();
const createdAt = Date.now();
/** @type {FlowerJob} */
const job = { const job = {
id, id,
createdAt: Date.now(), createdAt,
userInput, userInput,
moodAnalysis: null, moodAnalysis: null,
recipe: null, recipe: null,
@@ -71,31 +109,61 @@ export function createJob(userInput = {}) {
floristNote: null 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; return job;
} }
/** @param {string} jobId */ /** @param {string} jobId */
export function getJob(jobId) { export async function getJob(jobId) {
return jobs.get(jobId) ?? null; 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 {string} jobId
* @param {Partial<FlowerJob>} patch * @param {Partial<FlowerJob>} patch
*/ */
export function updateJob(jobId, patch) { export async function updateJob(jobId, patch) {
const job = jobs.get(jobId); const rowPatch = toRowPatch(patch);
if (!job) return null;
const updated = { ...job, ...patch }; const { data, error } = await getSupabaseClient()
jobs.set(jobId, updated); .from('flower_jobs')
return updated; .update(rowPatch)
.eq('id', jobId)
.select('*')
.maybeSingle();
if (error) {
throwSupabaseError(error, 'update flower job');
}
return data ? fromRow(data) : null;
} }
/** @param {string} jobId */ /** @param {string} jobId */
export function requireJob(jobId) { export async function requireJob(jobId) {
const job = getJob(jobId); const job = await getJob(jobId);
if (!job) { if (!job) {
throw new JobNotFoundError(jobId); throw new JobNotFoundError(jobId);
} }

View File

@@ -6,9 +6,7 @@ import { describeAiError } from '$lib/server/aiError.js';
*/ */
export function toErrorResponse(error) { export function toErrorResponse(error) {
if (error instanceof JobNotFoundError) { if (error instanceof JobNotFoundError) {
console.warn( console.warn(`[flower-flow] job_not_found (404) — ${error.message}`);
`[flower-flow] job_not_found (404) — ${error.message} (server restart wipes jobs)`
);
return new Response(JSON.stringify({ error: error.message, code: 'job_not_found' }), { return new Response(JSON.stringify({ error: error.message, code: 'job_not_found' }), {
status: 404, status: 404,
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }

View File

@@ -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;
}

View File

@@ -1,10 +1,6 @@
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { import { DEV_CARD_MESSAGE, DEV_USER_INPUT, DEV_USER_INPUT_WITH_NOTES } from '$lib/dev/fixtures.js';
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 { DEV_MOODBOARD_UPLOAD, DEV_SNS_UPLOAD } from '$lib/dev/uploadFixtures.js';
import { seedDevJob } from '$lib/server/dev/seedJob.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({ return json({
stage, stage,
@@ -47,9 +43,7 @@ export async function POST({ request }) {
moodboard: DEV_MOODBOARD_UPLOAD, moodboard: DEV_MOODBOARD_UPLOAD,
sns: DEV_SNS_UPLOAD sns: DEV_SNS_UPLOAD
}, },
...(stage === 'result' ...(stage === 'result' ? { selectedSize: 'M', floristNote: seeded.floristNote } : {})
? { selectedSize: 'M', floristNote: seeded.floristNote }
: {})
}, },
// create 폼 초기값 참고용 (relationship/occasion/style/budget만) // create 폼 초기값 참고용 (relationship/occasion/style/budget만)
formDefaults: DEV_USER_INPUT formDefaults: DEV_USER_INPUT

View File

@@ -2,6 +2,7 @@ import { dev } from '$app/environment';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { loadDevBouquetImages } from '$lib/server/dev/loadFixtureImages.js'; import { loadDevBouquetImages } from '$lib/server/dev/loadFixtureImages.js';
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.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 { mockImagePrompt, mockMoodAnalysis, mockRecipe } from '$lib/server/gemini/mock.js';
import { readJsonBody } from '$lib/server/http.js'; import { readJsonBody } from '$lib/server/http.js';
@@ -19,13 +20,13 @@ export async function POST({ request }) {
return json({ error: 'jobId is required' }, 400); return json({ error: 'jobId is required' }, 400);
} }
const job = requireJob(jobId); const job = await requireJob(jobId);
const moodAnalysis = job.moodAnalysis ?? mockMoodAnalysis(); const moodAnalysis = job.moodAnalysis ?? mockMoodAnalysis();
const recipe = job.recipe ?? mockRecipe(job.userInput); const recipe = job.recipe ?? mockRecipe(job.userInput);
const imagePrompt = job.imagePrompt ?? mockImagePrompt(recipe); 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({ return json({
jobId, jobId,

View File

@@ -5,6 +5,7 @@ import {
getImageProvider, getImageProvider,
isImageGenerationConfigured isImageGenerationConfigured
} from '$lib/server/gemini/image.js'; } from '$lib/server/gemini/image.js';
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js'; import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
/** /**
@@ -29,8 +30,9 @@ function generateForJob(jobId, recipe) {
const task = (async () => { const task = (async () => {
const imagePrompt = await buildImagePrompt(recipe); const imagePrompt = await buildImagePrompt(recipe);
const images = await generateAllSizeImages(imagePrompt); const generatedImages = await generateAllSizeImages(imagePrompt);
updateJob(jobId, { imagePrompt, images }); const images = await uploadGeneratedImages(jobId, generatedImages);
await updateJob(jobId, { imagePrompt, images });
return { imagePrompt, images }; return { imagePrompt, images };
})().finally(() => { })().finally(() => {
inFlight.delete(jobId); inFlight.delete(jobId);
@@ -50,7 +52,7 @@ export async function POST({ request }) {
return json({ error: 'jobId is required', code: 'bad_request' }, 400); return json({ error: 'jobId is required', code: 'bad_request' }, 400);
} }
const job = requireJob(jobId); const job = await requireJob(jobId);
if (!job.recipe) { if (!job.recipe) {
return json({ error: 'recipe is missing. Run recipe first.', code: 'bad_request' }, 400); return json({ error: 'recipe is missing. Run recipe first.', code: 'bad_request' }, 400);

View File

@@ -11,7 +11,7 @@ export async function GET({ url }) {
return json({ error: 'jobId is required' }, 400); return json({ error: 'jobId is required' }, 400);
} }
const job = requireJob(jobId); const job = await requireJob(jobId);
return json({ return json({
jobId: job.id, jobId: job.id,

View File

@@ -14,11 +14,11 @@ export async function POST({ request }) {
} }
const userInput = readUserInput(formData); const userInput = readUserInput(formData);
const job = createJob(userInput); const job = await createJob(userInput);
const imageBytes = new Uint8Array(await image.arrayBuffer()); const imageBytes = new Uint8Array(await image.arrayBuffer());
const moodAnalysis = await analyzeImageMood(imageBytes, image.type || 'image/jpeg', userInput); const moodAnalysis = await analyzeImageMood(imageBytes, image.type || 'image/jpeg', userInput);
updateJob(job.id, { moodAnalysis }); await updateJob(job.id, { moodAnalysis });
return json({ return json({
jobId: job.id, jobId: job.id,

View File

@@ -13,21 +13,21 @@ export async function POST({ request }) {
return json({ error: 'jobId is required' }, 400); return json({ error: 'jobId is required' }, 400);
} }
const job = requireJob(jobId); const job = await requireJob(jobId);
if (!job.moodAnalysis) { if (!job.moodAnalysis) {
return json({ error: 'moodAnalysis is missing. Run mood-analysis first.' }, 400); return json({ error: 'moodAnalysis is missing. Run mood-analysis first.' }, 400);
} }
if (body.userInput && typeof body.userInput === 'object') { if (body.userInput && typeof body.userInput === 'object') {
updateJob(jobId, { await updateJob(jobId, {
userInput: { ...job.userInput, .../** @type {Record<string, unknown>} */ (body.userInput) } userInput: { ...job.userInput, .../** @type {Record<string, unknown>} */ (body.userInput) }
}); });
} }
const currentJob = requireJob(jobId); const currentJob = await requireJob(jobId);
const recipe = await buildBouquetRecipe(currentJob.moodAnalysis, currentJob.userInput); const recipe = await buildBouquetRecipe(currentJob.moodAnalysis, currentJob.userInput);
updateJob(jobId, { recipe }); await updateJob(jobId, { recipe });
return json({ return json({
jobId, jobId,

View File

@@ -21,7 +21,7 @@ export async function POST({ request }) {
return json({ error: 'size must be one of S, M, or L' }, 400); 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)]; const selectedImage = job.images?.[/** @type {'S'|'M'|'L'} */ (size)];
if (!selectedImage) { if (!selectedImage) {
@@ -30,7 +30,7 @@ export async function POST({ request }) {
const floristNote = job.recipe ? await buildFloristNote(job.recipe) : null; const floristNote = job.recipe ? await buildFloristNote(job.recipe) : null;
updateJob(jobId, { await updateJob(jobId, {
selectedSize: /** @type {'S'|'M'|'L'} */ (size), selectedSize: /** @type {'S'|'M'|'L'} */ (size),
floristNote floristNote
}); });

View File

@@ -101,8 +101,8 @@
); );
// Do NOT persist the multi-MB base64 images in sessionStorage — Safari caps // Do NOT persist the multi-MB base64 images in sessionStorage — Safari caps
// it at ~5MB and throws "QuotaExceededError: The quota has been exceeded." // it at ~5MB and throws "QuotaExceededError: The quota has been exceeded."
// The images already live server-side in the job; the options and result // The images already live in Supabase Storage via the job; the options
// pages fetch them by jobId. We only keep lightweight metadata here. // and result pages fetch them by jobId. We only keep lightweight metadata here.
saveFlow({ saveFlow({
imagesJobId: jobId, imagesJobId: jobId,
imagePrompt: imageResult.imagePrompt, imagePrompt: imageResult.imagePrompt,
@@ -113,8 +113,7 @@
} catch (err) { } catch (err) {
if (!active) return; if (!active) return;
// The server lost this job (e.g. a dev-server restart wipes the in-memory // The stored jobId no longer resolves, so retrying is pointless — clear
// job store). The stored jobId is dead, so retrying is pointless — clear
// the stale flow and send the user back to re-upload. // the stale flow and send the user back to re-upload.
const code = err && typeof err === 'object' && 'code' in err ? err.code : ''; const code = err && typeof err === 'object' && 'code' in err ? err.code : '';
const stale = const stale =

22
supabase/schema.sql Normal file
View File

@@ -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;