diff --git a/src/lib/components/custom/claim-item-dialog.svelte b/src/lib/components/custom/claim-item-dialog.svelte index 8c7d0c0..9a974e9 100644 --- a/src/lib/components/custom/claim-item-dialog.svelte +++ b/src/lib/components/custom/claim-item-dialog.svelte @@ -23,7 +23,7 @@ -
+
diff --git a/src/lib/components/custom/item-listing.svelte b/src/lib/components/custom/item-listing.svelte index 86fcd4f..880c162 100644 --- a/src/lib/components/custom/item-listing.svelte +++ b/src/lib/components/custom/item-listing.svelte @@ -7,11 +7,13 @@ import XIcon from '@lucide/svelte/icons/x'; import PencilIcon from '@lucide/svelte/icons/pencil'; import NotebookPenIcon from '@lucide/svelte/icons/notebook-pen'; + import TrashIcon from '@lucide/svelte/icons/trash'; import StarIcon from '@lucide/svelte/icons/star'; + import ArchiveRestoreIcon from '@lucide/svelte/icons/archive-restore'; import { Button } from '$lib/components/ui/button'; import * as Tooltip from '$lib/components/ui/tooltip'; import { dateFormatOptions } from '$lib/shared'; - import { approveDenyItem } from '$lib/db/items.remote'; + import { approveDenyItem, restoreClaimedItem } from '$lib/db/items.remote'; import { invalidateAll } from '$app/navigation'; import NoImagePlaceholder from './no-image-placeholder.svelte'; @@ -98,11 +100,26 @@ Deny {/if} - + {#if item.claimedDate === null} + + {:else} + + + {/if}
{:else} diff --git a/src/lib/components/ui/switch/index.ts b/src/lib/components/ui/switch/index.ts new file mode 100644 index 0000000..f5533db --- /dev/null +++ b/src/lib/components/ui/switch/index.ts @@ -0,0 +1,7 @@ +import Root from "./switch.svelte"; + +export { + Root, + // + Root as Switch, +}; diff --git a/src/lib/components/ui/switch/switch.svelte b/src/lib/components/ui/switch/switch.svelte new file mode 100644 index 0000000..80661fd --- /dev/null +++ b/src/lib/components/ui/switch/switch.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/src/lib/consts.ts b/src/lib/consts.ts index 698219c..9dab79f 100644 --- a/src/lib/consts.ts +++ b/src/lib/consts.ts @@ -25,4 +25,5 @@ const EMAIL_REGEX = /^(?!\.)(?!.*\.\.)([a-z0-9_'+\-\.]*)[a-z0-9_'+\-]@([a-z0-9][a-z0-9\-]*\.)+[a-z]{2,}$/i; // Replace single quote with HTML entity or remove it from the character class -export const EMAIL_REGEX_STRING = EMAIL_REGEX.source.replace(/'/g, '''); +export const EMAIL_REGEX_STRING = + "^(?!\\.)(?!.*\\.\\.)([a-zA-Z0-9_'+\\-\\.]*)[a-zA-Z0-9_'+\\-]@([a-zA-Z0-9][a-zA-Z0-9\\-]*\\.)+[a-zA-Z]{2,}$"; diff --git a/src/lib/db/items.remote.ts b/src/lib/db/items.remote.ts index c5403fc..e19a480 100644 --- a/src/lib/db/items.remote.ts +++ b/src/lib/db/items.remote.ts @@ -58,3 +58,14 @@ export const approveDenyItem = command( } } ); + +export const restoreClaimedItem = command(v.number(), async (id) => { + const { cookies } = getRequestEvent(); + verifyJWT(cookies); + + const reponse = await sql` + UPDATE items + SET claimed_date = null + WHERE id = ${id}; + `; +}); diff --git a/src/lib/email/sender.server.ts b/src/lib/email/sender.server.ts index 809f0b7..fa4aab9 100644 --- a/src/lib/email/sender.server.ts +++ b/src/lib/email/sender.server.ts @@ -1,9 +1,8 @@ import nodemailer from 'nodemailer'; import sql from '$lib/db/db.server'; -import type { Item } from '$lib/types/item'; import jwt from 'jsonwebtoken'; import { type inquiryTokenPayload, Sender } from '$lib/types/inquiries'; -import { threadId } from 'node:worker_threads'; +import type { Item } from '$lib/types/item'; // Create a transporter object using SMTP transport export const transporter = nodemailer.createTransport({ @@ -47,7 +46,7 @@ export async function sendNewInquiryEmail(inquiryId: number) { // // `; - const item: Item = await sql` + const [item]: Item[] = await sql` SELECT i.*, json_agg( @@ -57,7 +56,7 @@ export async function sendNewInquiryEmail(inquiryId: number) { 'created_at', t.created_at, 'messages', m.messages ) - ) FILTER (WHERE t.id = ${threadId}) AS threads + ) FILTER ( WHERE t.id = ${inquiryId} ) AS threads FROM items i LEFT JOIN inquiry_threads t ON t.item_id = i.id @@ -77,49 +76,42 @@ export async function sendNewInquiryEmail(inquiryId: number) { const replyToken = jwt.sign(tokenPayload, process.env.JWT_SECRET!); console.log(item); + console.log(item.threads); console.log(replyToken); // Send mail with defined transport object - // await transporter.sendMail({ - // from: `Westuffind Notifier <${process.env.EMAIL_USER}>`, - // to: item.emails, - // // to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING - // replyTo: `${process.env.EMAIL_USER!.split('@')[0]}+${replyToken}${process.env.EMAIL_USER!.split('@')[1]}`, - // subject: 'New Item Inquiry!', - // text: `Someone has made an inquiry on the item with description: ${item.description}\nThey ask: ${item.threads![0].messages[0].body}\n\n\nRespond to this email directly, or click the below link to reply on Westuffinder\n${process.env.BASE_URL}/items/${item.id}/inquiries/${inquiryId}?token=${replyToken}` - // }); + await transporter.sendMail({ + from: `Westuffind Notifier <${process.env.EMAIL_USER}>`, + to: item.emails, + // to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING + replyTo: `${process.env.EMAIL_USER!.split('@')[0]}+${replyToken}${process.env.EMAIL_USER!.split('@')[1]}`, + subject: 'New Item Inquiry!', + text: `Someone has made an inquiry on the item with description: ${item.description}\nThey ask: ${item.threads![0].messages[0].body}\n\n\nRespond to this email directly, or click the below link to reply on Westuffinder\n${process.env.BASE_URL}/items/${item.id}/inquiries/${inquiryId}?token=${replyToken}` + }); } export async function sendInquiryMessageEmail(inquiryId: number, sender: Sender) { - const item: Item = await sql` - SELECT json_agg(item_data) AS result - FROM ( - SELECT - i.*, - ( - SELECT json_agg(thread_data) - FROM ( - SELECT - it.id, - it.item_id, - ( - SELECT json_agg(im) - FROM inquiry_messages im - WHERE im.thread_id = it.id - ORDER BY im.created_at - ) AS messages - FROM inquiry_threads it - WHERE it.id = ${inquiryId} - ) AS thread_data - ) AS threads - FROM items i - WHERE i.id = ( - SELECT item_id - FROM inquiry_threads - WHERE id = ${inquiryId} - ) - ) AS item_data; - - `; + const [item]: 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 = ${inquiryId} ) 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 + WHERE i.id = (SELECT item_id FROM inquiry_threads WHERE id = ${inquiryId}) + GROUP BY i.id;`; const tokenPayload: inquiryTokenPayload = { threadId: inquiryId, @@ -137,3 +129,20 @@ export async function sendInquiryMessageEmail(inquiryId: number, sender: Sender) text: `Someone has replied to the inquiry on the item with description: ${item.description}\nThey say: ${item.threads![0].messages[item.threads![0].messages.length - 1].body}\n\n\nRespond to this email directly, or click the below link to reply on Westuffinder\n${process.env.BASE_URL}/items/${item.id}/inquiries/${inquiryId}?token=${replyToken}` }); } + +export async function sendClaimEmail(id: number, email: string) { + const [item]: Item[] = await sql` + SELECT * FROM items WHERE id = ${id};`; + + if (!item.transferred) { + // Send mail with defined transport object + await transporter.sendMail({ + from: `Westuffind Notifier <${process.env.EMAIL_USER}>`, + to: item.emails, + replyTo: email, + // to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING + subject: 'Your Item was Claimed!', + text: `Someone has claimed your item with description: ${item.description}\nReply to this email explaining how they can pick up the item from you. Replies to this email go directly to the claimer.` + }); + } +} diff --git a/src/routes/account/+page.server.ts b/src/routes/account/+page.server.ts new file mode 100644 index 0000000..0fee0f8 --- /dev/null +++ b/src/routes/account/+page.server.ts @@ -0,0 +1,17 @@ +import type { PageServerLoad } from '$types'; +import sql from '$lib/db/db.server'; +import type { User } from '$lib/types/user'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!locals || !locals.user) { + throw Error('Need to be logged in!'); + } + + const [userData]: User[] = await sql` + SELECT * FROM users WHERE id = ${locals.user.id} + `; + + console.log(userData); + + return { userData }; +}; diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte index e01d156..c72dee1 100644 --- a/src/routes/account/+page.svelte +++ b/src/routes/account/+page.svelte @@ -1,10 +1,130 @@ + - \ No newline at end of file + + Account + + +
+ + +
+ Account Overview + ID #{data.userData.id} +
+
+ + + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + +
+

Notifications

+ +
+
+

Notify All Approved

+

+ Receive notifications when inquiries are approved. +

+
+ +
+ +
+
+

Notify All Turned In

+

+ Receive notifications when inquiries are turned in. +

+
+ +
+
+ +
+
+ Member since {formatDate(data.userData.createdAt)} ยท Last sign in {formatDate( + data.userData.lastSignIn + )} +
+ +
+ + +
+
+ +
+
+
\ No newline at end of file diff --git a/src/routes/items/+page.server.ts b/src/routes/items/+page.server.ts index ee37d16..02b16f4 100644 --- a/src/routes/items/+page.server.ts +++ b/src/routes/items/+page.server.ts @@ -6,6 +6,7 @@ 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'); @@ -344,7 +345,7 @@ export const actions: Actions = { id AS message_id; `; - // sendNewInquiryEmail(response[0].threadId); + await sendNewInquiryEmail(response[0].threadId); } catch (e) { return fail(400, { message: e instanceof Error ? e.message : 'Unknown error occurred', @@ -359,11 +360,17 @@ export const actions: Actions = { } const data = await request.formData(); - - let inquiry: string; + let email: string; try { - inquiry = getRequiredFormString(data, 'inquiry'); + 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', diff --git a/src/routes/items/+page.svelte b/src/routes/items/+page.svelte index b8337af..5b16393 100644 --- a/src/routes/items/+page.svelte +++ b/src/routes/items/+page.svelte @@ -83,14 +83,14 @@ Pending items - {/if} - {#each data.items as item (item.id)} - {#if item.approvedDate === null} - - {/if} - {/each} + {#each data.items as item (item.id)} + {#if item.approvedDate === null} + + {/if} + {/each} + {/if} {#if data.user && data.items.some( (item) => item.approvedDate !== null && item.claimedDate === null @@ -101,7 +101,7 @@ {/if} {#each data.items as item (item.id)} - {#if item.approvedDate !== null} + {#if item.approvedDate !== null && item.claimedDate === null} {/if} diff --git a/src/routes/items/[itemId]/inquiries/[inquiryId]/+page.server.ts b/src/routes/items/[itemId]/inquiries/[inquiryId]/+page.server.ts index 644f817..de6ab24 100644 --- a/src/routes/items/[itemId]/inquiries/[inquiryId]/+page.server.ts +++ b/src/routes/items/[itemId]/inquiries/[inquiryId]/+page.server.ts @@ -1,9 +1,11 @@ import type { Actions, PageServerLoad } from '$types'; -import { type inquiryTokenPayload, type Message, Sender } from '$lib/types/inquiries'; +import { type inquiryTokenPayload, Sender } from '$lib/types/inquiries'; import jwt from 'jsonwebtoken'; import { error } from '@sveltejs/kit'; import sql from '$lib/db/db.server'; import { getRequiredFormString } from '$lib/shared'; +import { sendInquiryMessageEmail } from '$lib/email/sender.server'; +import type { Item } from '$lib/types/item'; export const load: PageServerLoad = async ({ url, locals, params }) => { const token: string | undefined = url.searchParams.get('token'); @@ -11,7 +13,11 @@ export const load: PageServerLoad = async ({ url, locals, params }) => { const inquiryId: string = params.inquiryId; if (token) { - jwt.verify(token, process.env.JWT_SECRET!); + try { + jwt.verify(token, process.env.JWT_SECRET!); + } catch { + throw error(403, 'Your response token does not match this inquiry!'); + } } else if (!locals || !locals.user) { throw error( 403, @@ -19,11 +25,32 @@ export const load: PageServerLoad = async ({ url, locals, params }) => { ); } - const messages: Message[] = await sql` - SELECT * FROM inquiry_messages WHERE thread_id = ${inquiryId} - `; + const [item]: 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 = ${inquiryId} ) 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 + WHERE i.id = (SELECT item_id FROM inquiry_threads WHERE id = ${inquiryId}) + GROUP BY i.id;`; - return { messages }; + console.log(item); + + return { item }; }; export const actions: Actions = { @@ -37,13 +64,15 @@ export const actions: Actions = { if (locals && locals.user) { sender = Sender.ADMIN; } else if (token) { - const decoded: inquiryTokenPayload = jwt.verify( - token, - process.env.JWT_SECRET! - ) as inquiryTokenPayload; - sender = decoded.sender; + let decoded: inquiryTokenPayload; + try { + decoded = jwt.verify(token, process.env.JWT_SECRET!) as inquiryTokenPayload; + sender = decoded.sender; + } catch { + throw error(403, 'Your response token does not match this inquiry!'); + } - if (decoded.threadId !== inquiryId) { + if (decoded.threadId.toString() !== inquiryId.toString()) { throw error(403, 'Your response token does not match this inquiry!'); } } else { @@ -64,6 +93,15 @@ export const actions: Actions = { RETURNING id; `; + // TODO: Double check logic + let nextReplySender: Sender; + if (sender === Sender.ADMIN || sender === Sender.FINDER) { + nextReplySender = Sender.INQUIRER; + } else { + nextReplySender = Sender.FINDER; + } + await sendInquiryMessageEmail(response[0].threadId, nextReplySender); + return { success: true }; } } satisfies Actions; diff --git a/src/routes/items/[itemId]/inquiries/[inquiryId]/+page.svelte b/src/routes/items/[itemId]/inquiries/[inquiryId]/+page.svelte index 76a3c20..f8ba2d0 100644 --- a/src/routes/items/[itemId]/inquiries/[inquiryId]/+page.svelte +++ b/src/routes/items/[itemId]/inquiries/[inquiryId]/+page.svelte @@ -4,6 +4,11 @@ import { Textarea } from '$lib/components/ui/textarea'; import { type Message, Sender } from '$lib/types/inquiries'; import SendIcon from '@lucide/svelte/icons/send'; + import LocationIcon from '@lucide/svelte/icons/map-pinned'; + import { page } from '$app/state'; + import * as item from 'valibot'; + import NoImagePlaceholder from '$lib/components/custom/no-image-placeholder.svelte'; + import { Separator } from '$lib/components/ui/separator'; let { data } = $props(); @@ -33,35 +38,59 @@
+
+ + {#if data.item.image} + Lost item + {:else} + + + + + + + {/if} +
+
{data.item.description}
+ {#if data.item.foundLocation} +
+ +
{data.item.foundLocation}
+
+ {/if} +
+
+ +
- {#each data.messages as msg, index (msg.id)} -
- -
-
- {senderLabel(msg.sender)} + {#if data.item.threads} + {#each data.item.threads[0].messages as msg (msg.id)} +
+ +
+
+ {senderLabel(msg.sender)} +
- - {#if index < data.messages.length - 1} -
- {/if} -
- -
-

{msg.body}

- {msg.createdAt.toLocaleString()} + +
+

{msg.body}

+ {msg.createdAt.toLocaleString()} +
-
- {/each} + {/each} + {/if}
-
+