feat: use Supabase for flower job storage
This commit is contained in:
@@ -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
98
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
75
src/lib/server/flowerFlow/imageStorage.js
Normal file
75
src/lib/server/flowerFlow/imageStorage.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
|||||||
49
src/lib/server/supabase.js
Normal file
49
src/lib/server/supabase.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
22
supabase/schema.sql
Normal 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;
|
||||||
Reference in New Issue
Block a user