diff --git a/apps/www/src/lib/services/email.service.tsx b/apps/www/src/lib/services/email.service.tsx index 87ede21b..50549451 100644 --- a/apps/www/src/lib/services/email.service.tsx +++ b/apps/www/src/lib/services/email.service.tsx @@ -1,5 +1,5 @@ import { dev } from '$app/environment'; -import { ContactUsEmail, InvitationEmail } from '@programmerbar/emails'; +import { ContactUsEmail, InvitationEmail, ShiftEmail } from '@programmerbar/emails'; import type { CreateEmailOptions, Resend } from 'resend'; import { render } from '@react-email/render'; @@ -16,6 +16,46 @@ export type InvitationEmailProps = { email: string; }; +export type ShiftEmailProps = { + shift: { + startAt: string; + endAt: string; + summary: string; + description?: string; + }; + user: { + name: string; + email: string; + }; +}; + +function generateICS(shift: { + startAt: string; + endAt: string; + summary: string; + description?: string; +}): string { + const uid = `${Date.now()}@programmerbar.no`; + const formatDate = (dateStr: string) => + new Date(dateStr).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + const dtstamp = formatDate(new Date().toISOString()); + const dtstart = formatDate(shift.startAt); + const dtend = formatDate(shift.endAt); + + return `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Programmerbar//Shift Notification//EN +BEGIN:VEVENT +UID:${uid} +DTSTAMP:${dtstamp} +DTSTART:${dtstart} +DTEND:${dtend} +SUMMARY:${shift.summary} +DESCRIPTION:${shift.description || ''} +END:VEVENT +END:VCALENDAR`; +} + export class EmailService { #resend: Resend; @@ -41,6 +81,24 @@ export class EmailService { }); } + async sendShiftEmail(data: ShiftEmailProps) { + const icsContent = generateICS(data.shift); + + await this.sendEmail({ + from: FROM_EMAIL, + subject: 'Du har f�tt en vakt', + to: [data.user.email], + html: await render(ShiftEmail({ ...data })), + attachments: [ + { + filename: 'shift.ics', + content: icsContent, + contentType: 'text/calendar' + } + ] + }); + } + private async sendEmail(payload: CreateEmailOptions) { if (dev) { console.log('#############################'); @@ -48,9 +106,15 @@ export class EmailService { console.log('#############################'); console.log('########### EMAIL ############'); + console.log(`Sending real email to: ${payload.to}`); console.log(payload.html); console.log('#############################'); + if (payload.attachments) { + console.log('########### ATTACHMENTS ############'); + console.log(payload.attachments); + } + return; } diff --git a/apps/www/src/lib/validators.ts b/apps/www/src/lib/validators.ts index cf99e5ca..b4c4f8c4 100644 --- a/apps/www/src/lib/validators.ts +++ b/apps/www/src/lib/validators.ts @@ -22,3 +22,16 @@ export const ContactUsSchema = zfd.formData({ export const CreateInvitationSchema = z.object({ email: z.string().email() }); + +export const CreateEmailShiftSchema = z.object({ + user: z.object({ + name: z.string().min(1), + email: z.string().email() + }), + shift: z.object({ + startAt: z.coerce.date(), + endAt: z.coerce.date(), + summary: z.string().min(1), + description: z.string().optional() + }) +}); diff --git a/apps/www/src/routes/api/events/+server.ts b/apps/www/src/routes/api/events/+server.ts index 9771eefd..3503e8a2 100644 --- a/apps/www/src/routes/api/events/+server.ts +++ b/apps/www/src/routes/api/events/+server.ts @@ -1,5 +1,6 @@ import { CreateEventSchema } from '$lib/validators'; import type { RequestHandler } from './$types'; +import type { ShiftEmailProps } from '$lib/services/email.service'; export const POST: RequestHandler = async ({ request, locals }) => { if (!locals.user) { @@ -7,7 +8,6 @@ export const POST: RequestHandler = async ({ request, locals }) => { } const { name, date, shifts: jshifts } = await request.json().then(CreateEventSchema.parse); - const event = await locals.eventService.create(name, date); if (!event) { @@ -21,15 +21,57 @@ export const POST: RequestHandler = async ({ request, locals }) => { })); const createdShifts = await locals.eventService.createShifts(shiftsToInsert); - const userShiftsToInsert = createdShifts?.flatMap((shift, shiftIndex) => { return jshifts[shiftIndex].users.map((user) => ({ shiftId: shift.id, userId: user })); }); - await locals.eventService.createUserShifts(userShiftsToInsert ?? []); - return new Response(null, { status: 201 }); + const emailPromises = []; + + if (createdShifts && createdShifts.length > 0) { + for (let i = 0; i < createdShifts.length; i++) { + const shift = createdShifts[i]; + const shiftData = jshifts[i]; + + for (const userId of shiftData.users) { + const user = await locals.userService.findById(userId); + + if (user && user.email) { + const emailData: ShiftEmailProps = { + user: { + name: user.name || 'Frivillig', + email: user.email + }, + shift: { + startAt: new Date(shift.startAt).toISOString(), + endAt: new Date(shift.endAt).toISOString(), + summary: `Vakt: ${name}`, + description: `Du har f�tt en vakt! P� "${name}".` + } + }; + + emailPromises.push(locals.emailService.sendShiftEmail(emailData)); + + console.log(`Sending shift email to ${user.email} for shift ${shift.id}`); + } + } + } + } + + if (emailPromises.length > 0) { + try { + await Promise.allSettled(emailPromises); + console.log(`Sent ${emailPromises.length} shift notification emails`); + } catch (emailError) { + console.error('Error sending shift emails:', emailError); + } + } + + return new Response(JSON.stringify({ eventId: event.id }), { + status: 201, + headers: { 'Content-Type': 'application/json' } + }); }; diff --git a/apps/www/src/routes/portal/arrangementer/[id]/+page.server.ts b/apps/www/src/routes/portal/arrangementer/[id]/+page.server.ts index 2def4818..6d024e44 100644 --- a/apps/www/src/routes/portal/arrangementer/[id]/+page.server.ts +++ b/apps/www/src/routes/portal/arrangementer/[id]/+page.server.ts @@ -15,8 +15,14 @@ export const load: PageServerLoad = async ({ locals, params }) => { export const actions: Actions = { delete: async ({ params, locals }) => { - await locals.eventService.delete(params.id); - throw redirect(303, '/portal/arrangementer'); + if (locals.user?.role === 'board') { + await locals.eventService.delete(params.id); + throw redirect(303, '/portal/arrangementer'); + } + + return fail(401, { + message: 'Unauthorized' + }); }, join: async ({ request, locals }) => { if (!locals.user) { diff --git a/apps/www/src/routes/portal/arrangementer/[id]/+page.svelte b/apps/www/src/routes/portal/arrangementer/[id]/+page.svelte index f7c99449..81c2f091 100644 --- a/apps/www/src/routes/portal/arrangementer/[id]/+page.svelte +++ b/apps/www/src/routes/portal/arrangementer/[id]/+page.svelte @@ -45,9 +45,10 @@
- Farlig - -
- -
+ {#if data.user?.role === 'board'} + Farlig +
+ +
+ {/if}
diff --git a/apps/www/src/routes/portal/arrangementer/ny/+page.svelte b/apps/www/src/routes/portal/arrangementer/ny/+page.svelte index 69d1b76d..ec37d61b 100644 --- a/apps/www/src/routes/portal/arrangementer/ny/+page.svelte +++ b/apps/www/src/routes/portal/arrangementer/ny/+page.svelte @@ -9,27 +9,43 @@ import { differenceInHours } from 'date-fns'; let { data } = $props(); - let error = $state(''); - + let successMessage = $state(''); + let isSubmitting = $state(false); let createEventState = new CreateEventState(); const handleSubmit: EventHandler = async (e) => { e.preventDefault(); - if (!createEventState.isValid()) { error = 'Vennligst fyll ut alle feltene'; console.log(createEventState.json()); return; } - const response = await fetch('/api/events', { - method: 'POST', - body: JSON.stringify(createEventState.json()) - }); - - if (response.status === 201) { - createEventState.reset(); + isSubmitting = true; + error = ''; + successMessage = ''; + + try { + const response = await fetch('/api/events', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(createEventState.json()) + }); + + if (response.status === 201) { + successMessage = 'Arrangement opprettet! E-post er sendt.'; + createEventState.reset(); + } else { + error = 'Noe gikk galt ved oppretting av arrangement'; + } + } catch (err) { + error = 'Server error'; + console.error(err); + } finally { + isSubmitting = false; } }; @@ -41,7 +57,15 @@ Nytt arrangement {#if error} -

{error}

+
+

{error}

+
+{/if} + +{#if successMessage} +
+

{successMessage}

+
{/if}
@@ -59,7 +83,6 @@ type="datetime-local" required /> - {#each createEventState.shifts as shift, i} {@const shiftLength = differenceInHours(shift.endAt, shift.startAt)}
@@ -70,7 +93,6 @@ > -

Vakt {i + 1}

- {#if shiftLength >= 4} NB: Vakten er lengre enn 4 timer! {/if} - Ansvarlige - +
+ Alle ansvarlige vil få en e-post med kalenderinvitasjon når arrangementet opprettes. +
{#each createEventState.shifts[i].users as user, j (user)}
Ingen ansvarlige. Husk å legge til.

{/each} -
{/each} - - - + diff --git a/internal/emails/emails/index.ts b/internal/emails/emails/index.ts index 1ad7fae3..1755f150 100644 --- a/internal/emails/emails/index.ts +++ b/internal/emails/emails/index.ts @@ -6,3 +6,8 @@ export { default as InvitationEmail, type InvitationEmailProps, } from "./invitation"; +export { + default as ShiftEmail, + type ShiftEmailProps, +} + from "./shiftemail"; diff --git a/internal/emails/emails/shiftemail.tsx b/internal/emails/emails/shiftemail.tsx new file mode 100644 index 00000000..e1e71966 --- /dev/null +++ b/internal/emails/emails/shiftemail.tsx @@ -0,0 +1,79 @@ +import { + Body, + Container, + Head, + Html, + Text, + Tailwind, +} from "@react-email/components"; + +export interface ShiftEmailProps { + shift: { + startAt: string; + endAt: string; + summary: string; + description?: string; + }; + user: { + name: string; + email: string; + }; +} + +const ShiftEmail = ({ shift, user }: ShiftEmailProps) => { + return ( + + + + + + + Hei {user.name}, du har fått en ny vakt! + + + + Du har blitt tildelt en vakt med følgende detaljer: + + + + Fra: {new Date(shift.startAt).toLocaleString()} + + + + Til: {new Date(shift.endAt).toLocaleString()} + + + + Oppsummering: {shift.summary} + + + {shift.description && ( + + Beskrivelse: {shift.description} + + )} + + + Kalenderinvitasjonen er vedlagt denne e-posten. + + + + + + ); +}; + +ShiftEmail.PreviewProps = { + shift: { + startAt: new Date().toISOString(), + endAt: new Date(Date.now() + 3600000).toISOString(), + summary: "Vakt for programmerbar fredagsåpent", + description: "Programmerbar.", + }, + user: { + name: "Ola Nordmann", + email: "ola.nordmann@example.com", + }, +} as ShiftEmailProps; + +export default ShiftEmail;