Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion apps/www/src/lib/services/email.service.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;

Expand All @@ -41,16 +81,40 @@ 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('#############################');
console.log('# NOT SENDING EMAILS IN DEV #');
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;
}

Expand Down
13 changes: 13 additions & 0 deletions apps/www/src/lib/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
});
50 changes: 46 additions & 4 deletions apps/www/src/routes/api/events/+server.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
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) {
return new Response(null, { status: 401 });
}

const { name, date, shifts: jshifts } = await request.json().then(CreateEventSchema.parse);

const event = await locals.eventService.create(name, date);

if (!event) {
Expand All @@ -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' }
});
};
10 changes: 8 additions & 2 deletions apps/www/src/routes/portal/arrangementer/[id]/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
11 changes: 6 additions & 5 deletions apps/www/src/routes/portal/arrangementer/[id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@
</section>

<section class="mt-8">
<Heading level={2}>Farlig</Heading>

<form action="?/delete" method="post" use:enhance>
<Button intent="danger" class="mt-4">Slett arrangement</Button>
</form>
{#if data.user?.role === 'board'}
<Heading level={2}>Farlig</Heading>
<form action="?/delete" method="post" use:enhance>
<Button intent="danger" class="mt-4">Slett arrangement</Button>
</form>
{/if}
</section>
61 changes: 41 additions & 20 deletions apps/www/src/routes/portal/arrangementer/ny/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<SubmitEvent, HTMLFormElement> = 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;
}
};
</script>
Expand All @@ -41,7 +57,15 @@
<Heading class="mb-4">Nytt arrangement</Heading>

{#if error}
<p class="text-red-500">{error}</p>
<div class="mb-4 rounded-md bg-red-50 p-4 text-red-700">
<p>{error}</p>
</div>
{/if}

{#if successMessage}
<div class="mb-4 rounded-md bg-green-50 p-4 text-green-700">
<p>{successMessage}</p>
</div>
{/if}

<form onsubmit={handleSubmit} class="space-y-2">
Expand All @@ -59,7 +83,6 @@
type="datetime-local"
required
/>

{#each createEventState.shifts as shift, i}
{@const shiftLength = differenceInHours(shift.endAt, shift.startAt)}
<div class="relative flex flex-col space-y-2 rounded-lg border border-border p-4">
Expand All @@ -70,7 +93,6 @@
>
<X class="h-4 w-4" />
</button>

<h2 class="text-lg font-semibold">Vakt {i + 1}</h2>
<FormInput
label="Start"
Expand All @@ -86,13 +108,13 @@
type="datetime-local"
required
/>

{#if shiftLength >= 4}
<span class="text-sm font-medium text-orange-500">NB: Vakten er lengre enn 4 timer!</span>
{/if}

<span class="mt-8 text-sm font-medium">Ansvarlige</span>

<div class="text-xs text-gray-500">
Alle ansvarlige vil få en e-post med kalenderinvitasjon når arrangementet opprettes.
</div>
{#each createEventState.shifts[i].users as user, j (user)}
<div class="flex items-center gap-2">
<Combobox
Expand Down Expand Up @@ -120,14 +142,13 @@
{:else}
<p class="text-sm text-gray-600">Ingen ansvarlige. Husk å legge til.</p>
{/each}

<Button type="button" onclick={() => createEventState.addUserToShift(i)}
>Legg til bruker</Button
>
</div>
{/each}

<Button type="button" onclick={() => createEventState.addShift()}>Legg til vakt</Button>

<Button type="submit">Lagre</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Lagrer...' : 'Lagre'}
</Button>
</form>
5 changes: 5 additions & 0 deletions internal/emails/emails/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ export {
default as InvitationEmail,
type InvitationEmailProps,
} from "./invitation";
export {
default as ShiftEmail,
type ShiftEmailProps,
}
from "./shiftemail";
Loading