import type { Actions, PageServerLoad } from '$types'; import { fail } from '@sveltejs/kit'; import path from 'path'; import sql from '$lib/db/db.server'; import sharp from 'sharp'; import { writeFileSync } from 'node:fs'; import type { Item } from '$lib/types/item'; import { getFormString, getRequiredFormString } from '$lib/shared'; export const load: PageServerLoad = async ({ url, locals }) => { const searchQuery = url.searchParams.get('search'); // If the user is logged in, fetch items together with their threads (each thread contains its first message) if (locals && locals.user) { try { const items: Item[] = await sql` SELECT i.*, json_agg( jsonb_build_object( 'id', t.id, 'item_id', t.item_id, 'created_at', t.created_at, 'messages', m.messages ) ) FILTER (WHERE t.id IS NOT NULL) AS threads FROM items i LEFT JOIN inquiry_threads t ON t.item_id = i.id LEFT JOIN LATERAL ( SELECT json_agg(im.* ORDER BY im.created_at) AS messages FROM inquiry_messages im WHERE im.thread_id = t.id ) m ON TRUE ${searchQuery ? sql`WHERE word_similarity(${searchQuery}, i.description) > 0.3` : sql``} GROUP BY i.id; `; items.forEach((item) => item.threads?.forEach((thread) => (thread.createdAt = new Date(thread.createdAt))) ); return { items }; // type DBMessage = { // id: number; // sender: keyof typeof Sender | string; // body: string; // createdAt: string; // }; // type DBThread = { id: number; messages: DBMessage[] }; // type RowsItem = { threads: DBThread[]; [key: string]: unknown }; // // const rows = (await sql` // SELECT i.*, COALESCE( // ( // SELECT json_agg(json_build_object('id', t.id, 'messages', json_build_array(json_build_object('id', m.id, 'sender', m.sender, 'body', m.body, 'createdAt', m.created_at))), 'createdAt' , t.created_at ORDER BY t.id) // FROM inquiry_threads t // LEFT JOIN LATERAL ( // SELECT id, sender, body, created_at // FROM inquiry_messages // WHERE thread_id = t.id // ORDER BY created_at ASC // LIMIT 1 // ) m ON true // WHERE t.item_id = i.id // ), '[]'::json // ) AS threads // FROM items i // ${ // searchQuery // ? sql`WHERE word_similarity(${searchQuery}, i.description) > 0.3 // ORDER BY word_similarity(${searchQuery}, i.description)` // : sql`` // }; // `) as RowsItem[]; // // // `rows` contains items with a `threads` JSON column. Attach parsed threads to each item. // const items: Item[] = rows.map((r) => { // const item: Record = { ...r }; // const rawThreads = (r.threads || []) as DBThread[]; // console.log(rawThreads); // if (rawThreads.length === 0) { // item.threads = null; // } else { // item.threads = rawThreads.map((t) => ({ // id: t.id, // messages: (t.messages || []).map((m) => ({ // id: m.id, // // coerce sender to our Sender enum type if possible // sender: (Object.values(Sender).includes(m.sender as Sender) // ? (m.sender as Sender) // : (m.sender as unknown)) as Message['sender'], // body: m.body, // createdAt: new Date(m.createdAt) // })) // })); // } // return item as unknown as Item; // }); // // return { items }; } catch (err) { console.error('Failed to load items with threads:', err); // Fallback to non-joined fetch so the page still loads } } // Not logged in or fallback: simple item list (no threads) let items: Item[]; if (!searchQuery) { items = await sql`SELECT * FROM items;`; } else { items = await sql` SELECT * FROM items WHERE word_similarity(${searchQuery}, description) > 0.3 ORDER BY word_similarity(${searchQuery}, description); `; } return { items }; }; export const actions: Actions = { create: async ({ request }) => { const data = await request.formData(); let description: string; let foundLocation: string | null; let email: string | null; let location: string | null; try { description = getRequiredFormString(data, 'description'); foundLocation = getFormString(data, 'foundLocation'); email = getFormString(data, 'email'); location = getFormString(data, 'location'); const file = data.get('image'); // if (file === null) // return fail(400, { // message: `Missing required field image.`, // success: false // }); if (!email && location !== 'turnedIn') { fail(400, { message: "Email is required if it is still in finder's possession", success: false }); } let outputBuffer: Buffer | undefined; if (file) { if (!(file instanceof File)) { return fail(400, { message: 'No file uploaded or file is invalid', success: false }); } // Convert File → Buffer const inputBuffer = Buffer.from(await file.arrayBuffer()); // Detect format (Sharp does this internally) const image = sharp(inputBuffer).rotate(); // image = image.rotate(); const metadata = await image.metadata(); if (metadata.format === 'jpeg') { // Already JPG → keep as-is outputBuffer = inputBuffer; } else { // Convert to JPG outputBuffer = await image .jpeg({ quality: 90 }) // adjust if needed .toBuffer(); } } const response = await sql` INSERT INTO items ( emails, description, transferred, found_location, image ) VALUES ( ${ location === 'turnedIn' ? sql`( SELECT array_agg(DISTINCT email) FROM ( SELECT ${email} AS email UNION ALL SELECT u.email FROM users u WHERE (u.settings->>'notifyAllTurnedInInquiries')::boolean = true ) t )` : [email] }, ${description}, ${location === 'turnedIn'}, ${foundLocation}, ${file instanceof File} ) RETURNING id; `; if (outputBuffer) { try { // It's a good idea to validate the filename to prevent path traversal attacks const savePath = path.join('uploads', `${response[0]['id']}.jpg`); writeFileSync(savePath, outputBuffer); } catch (err) { console.error('File upload failed:', err); return fail(500, { message: 'Internal server error during file upload', success: false }); } } } catch (e) { return fail(400, { message: e instanceof Error ? e.message : 'Unknown error occurred', success: false }); } return { success: true }; }, edit: async ({ request, url }) => { const idParam = url.searchParams.get('id'); if (!idParam) { return fail(400, { message: 'Missing id for edit action', success: false }); } const id = Number(idParam); if (!Number.isFinite(id) || id <= 0) { return fail(400, { message: 'Invalid id provided', success: false }); } const data = await request.formData(); let description: string; let foundLocation: string | null; let email: string | null; let location: string | null; try { description = getRequiredFormString(data, 'description'); foundLocation = getFormString(data, 'foundLocation'); email = getFormString(data, 'email'); location = getFormString(data, 'location'); if (!email && location !== 'turnedIn') { return fail(400, { message: "Email is required if it is still in finder's possession", success: false }); } let file = data.get('image'); if (file instanceof File && file.size === 0) { file = null; } let outputBuffer: Buffer | undefined; if (file) { if (!(file instanceof File) || file.size === 0) { return fail(400, { message: 'No file uploaded or file is invalid', success: false }); } // Convert File → Buffer const inputBuffer = Buffer.from(await file.arrayBuffer()); const image = sharp(inputBuffer).rotate(); const metadata = await image.metadata(); if (metadata.format === 'jpeg') { outputBuffer = inputBuffer; } else { outputBuffer = await image.jpeg({ quality: 90 }).toBuffer(); } } // Build and run UPDATE query. Only touch `image` column when a new file was uploaded. const response = await sql` UPDATE items SET description = ${description}, transferred = ${location === 'turnedIn'}, found_location = ${foundLocation} ${file ? sql`,image = ${file instanceof File}` : sql``} WHERE id = ${id} RETURNING id; `; if (outputBuffer) { try { const savePath = path.join('uploads', `${response[0]['id']}.jpg`); writeFileSync(savePath, outputBuffer); } catch (err) { console.error('File upload failed:', err); return fail(500, { message: 'Internal server error during file upload', success: false }); } } } catch (e) { return fail(400, { message: e instanceof Error ? e.message : 'Unknown error occurred', success: false }); } return { success: true }; }, inquire: async ({ request, url }) => { const id = url.searchParams.get('id'); if (!id) { return fail(400, { message: 'Missing id!', success: false }); } const data = await request.formData(); let inquiry: string; let email: string; try { inquiry = getRequiredFormString(data, 'inquiry'); email = getRequiredFormString(data, 'email'); const response = await sql` WITH new_thread AS ( INSERT INTO inquiry_threads (item_id, inquirer_email) VALUES (${id}, ${email}) RETURNING id ) INSERT INTO inquiry_messages (thread_id, sender, body) SELECT id, 'inquirer', ${inquiry} FROM new_thread RETURNING thread_id, id AS message_id; `; // sendNewInquiryEmail(response[0].threadId); } catch (e) { return fail(400, { message: e instanceof Error ? e.message : 'Unknown error occurred', success: false }); } }, claim: async ({ request, url }) => { const id = url.searchParams.get('id'); if (!id) { return fail(400, { message: 'Missing id!', success: false }); } const data = await request.formData(); let inquiry: string; try { inquiry = getRequiredFormString(data, 'inquiry'); } catch (e) { return fail(400, { message: e instanceof Error ? e.message : 'Unknown error occurred', success: false }); } } // describe: async ({ request }) => { // const data = await request.formData(); // // let image = data.get('image'); // if (!(image instanceof File) || image.size === 0) { // return fail(400, { message: 'No file uploaded or file is invalid', success: false }); // } // // // Convert File → Buffer // const inputBuffer = Buffer.from(await file.arrayBuffer()); // // // Detect format (Sharp does this internally) // const image = sharp(inputBuffer).rotate(); // // const outputBuffer = await image // .rotate() // .resize({ fit: 'outside', height: 400, width: 400 }) // .jpeg({ quality: 90 }) // adjust if needed // .toBuffer(); // // const description = LLMDescribe(`data:image/jpeg;base64,${outputBuffer.toString('base64')}`); // console.log(description); // // return description; // } } satisfies Actions;