fbla26/src/routes/items/+page.server.ts
2026-02-07 00:45:35 -06:00

407 lines
12 KiB
TypeScript

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';
import { sendClaimEmail, sendNewInquiryEmail } from '$lib/email/sender.server';
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<string, unknown> = { ...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;
`;
await 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 email: string;
try {
email = getRequiredFormString(data, 'email');
const response = await sql`
UPDATE items SET claimed_date = NOW()
WHERE id = ${id};
`;
await sendClaimEmail(id, email);
} 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;