400 lines
11 KiB
TypeScript
400 lines
11 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';
|
|
|
|
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;
|
|
`;
|
|
|
|
// 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;
|