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)
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.

98
package-lock.json generated
View File

@@ -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",

View File

@@ -32,6 +32,7 @@
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@supabase/supabase-js": "^2.108.1",
"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) {
if (image?.url) return image.url;
if (!image?.base64) return '';
return `data:${image.mimeType || 'image/png'};base64,${image.base64}`;
}

View File

@@ -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<string, unknown>} 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,

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 { 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<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] */
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<FlowerJob>} 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);
}

View File

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

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 { 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown>} */ (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,

View File

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

View File

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

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;