diff --git a/src/routes/(app)/booking/+page.svelte b/src/routes/(app)/booking/+page.svelte index 0c188c977..480145e3a 100644 --- a/src/routes/(app)/booking/+page.svelte +++ b/src/routes/(app)/booking/+page.svelte @@ -4,7 +4,7 @@ import { isAuthorized } from "$lib/utils/authorization"; import { page } from "$app/stores"; import apiNames from "$lib/utils/apiNames"; - import StatusComponent from "./admin/StatusComponent.svelte"; + import StatusComponent from "./StatusComponent.svelte"; import dayjs from "dayjs"; import ConfirmDialog from "$lib/components/ConfirmDialog.svelte"; import BookingCalendar from "./BookingCalendar.svelte"; @@ -60,6 +60,7 @@ diff --git a/src/routes/(app)/booking/BookingEditor.svelte b/src/routes/(app)/booking/BookingEditor.svelte index bf8bd1db2..b554b9879 100644 --- a/src/routes/(app)/booking/BookingEditor.svelte +++ b/src/routes/(app)/booking/BookingEditor.svelte @@ -3,15 +3,21 @@ import type { BookingSchema } from "./schema"; import { superForm } from "$lib/utils/client/superForms"; import * as m from "$paraglide/messages"; - import type { Bookable } from "@prisma/client"; + import type { Bookable, BookingRequest } from "@prisma/client"; + import StatusComponent from "./StatusComponent.svelte"; + type BookingRequestWithBookables = BookingRequest & { bookables: Bookable[] }; export let data: { form: SuperValidated>; bookables: Bookable[]; + booking?: BookingRequestWithBookables; + allBookingRequests?: BookingRequestWithBookables[]; }; + $: bookingRequest = data.booking; + const { form, errors, enhance, constraints } = superForm(data.form); - export let mode: "create" | "edit" = "create"; + export let mode: "create" | "edit" | "review" = "create"; let start = $form.start; let end = $form.end; @@ -60,6 +66,23 @@
+ {#if mode === "review"} +
+ + {#if bookingRequest && data.allBookingRequests} + + {/if} +
+ {/if}
0} @@ -73,6 +96,7 @@ name="bookables" value={bookable.id} bind:group={$form.bookables} + disabled={mode === "review"} /> {bookable.name} @@ -89,6 +113,7 @@ bind:value={start} on:change={handleStartChange} {...$constraints.start} + disabled={mode === "review"} /> @@ -103,6 +128,7 @@ bind:value={end} on:change={handleEndChange} {...$constraints.end} + disabled={mode === "review"} /> @@ -114,15 +140,42 @@ class="input input-bordered w-full" bind:value={$form.name} {...$constraints.name} + disabled={mode === "review"} /> -
- {m.booking_goBack()} - {#if mode === "edit"} - - {:else if mode === "create"} - - {/if} -
+ {#if mode === "review" && bookingRequest} +
+ + + +
+ {/if} + + {#if mode !== "review"} +
+ {m.booking_goBack()} + {#if mode === "edit"} + + {:else if mode === "create"} + + {/if} +
+ {/if} diff --git a/src/routes/(app)/booking/admin/StatusComponent.svelte b/src/routes/(app)/booking/StatusComponent.svelte similarity index 92% rename from src/routes/(app)/booking/admin/StatusComponent.svelte rename to src/routes/(app)/booking/StatusComponent.svelte index 844b8e175..92a8c7b35 100644 --- a/src/routes/(app)/booking/admin/StatusComponent.svelte +++ b/src/routes/(app)/booking/StatusComponent.svelte @@ -2,10 +2,14 @@ import type { Bookable, BookingRequest } from "@prisma/client"; import dayjs from "dayjs"; import * as m from "$paraglide/messages"; + import { twMerge } from "tailwind-merge"; type T = BookingRequest & { bookables: Bookable[] }; export let bookingRequest: T; export let bookingRequests: T[]; + let clazz: string | undefined = undefined; + export { clazz as class }; + $: otherBookingRequests = bookingRequests.filter( (br) => br.id !== bookingRequest.id && br.status !== "DENIED", ); @@ -29,7 +33,7 @@ $: conflictWarning = conflict && !conflictError; -
+
{#if bookingRequest.status === "ACCEPTED"}
{m.booking_accepted()} diff --git a/src/routes/(app)/booking/[id]/edit/+page.server.ts b/src/routes/(app)/booking/[id]/edit/+page.server.ts index 2927a5114..3bf3c8c34 100644 --- a/src/routes/(app)/booking/[id]/edit/+page.server.ts +++ b/src/routes/(app)/booking/[id]/edit/+page.server.ts @@ -5,33 +5,14 @@ import { redirect } from "$lib/utils/redirect"; import * as m from "$paraglide/messages"; import { isAuthorized } from "$lib/utils/authorization"; import apiNames from "$lib/utils/apiNames"; -import { error } from "@sveltejs/kit"; -import dayjs from "dayjs"; +import { getBookingRequestOrThrow, getSuperValidatedForm } from "../../utils"; export const load = async ({ locals, params }) => { const { prisma } = locals; const bookables = await prisma.bookable.findMany(); - const bookingRequest = await prisma.bookingRequest.findUnique({ - where: { id: params.id }, - include: { bookables: true }, - }); - - if (!bookingRequest) { - throw error(404, m.booking_errors_notFound()); - } - - const initialData = { - name: bookingRequest.event ?? undefined, - start: bookingRequest.start - ? dayjs(bookingRequest.start).format("YYYY-MM-DDTHH:MM") - : undefined, - end: bookingRequest.end - ? dayjs(bookingRequest.end).format("YYYY-MM-DDTHH:MM") - : undefined, - bookables: bookingRequest.bookables?.map((bookable) => bookable.id), - }; - const form = await superValidate(initialData, zod(bookingSchema)); + const bookingRequest = await getBookingRequestOrThrow(prisma, params.id); + const form = await getSuperValidatedForm(bookingRequest); return { bookables, form, booking: bookingRequest }; }; diff --git a/src/routes/(app)/booking/admin/+page.server.ts b/src/routes/(app)/booking/admin/+page.server.ts index 1885ea050..5e76f66e4 100644 --- a/src/routes/(app)/booking/admin/+page.server.ts +++ b/src/routes/(app)/booking/admin/+page.server.ts @@ -1,94 +1,15 @@ import apiNames from "$lib/utils/apiNames"; import { authorize } from "$lib/utils/authorization"; -import sendNotification from "$lib/utils/notifications"; -import { NotificationType } from "$lib/utils/notifications/types"; -import dayjs from "dayjs"; -import type { Actions, PageServerLoad } from "./$types"; +import type { PageServerLoad } from "./$types"; +import { actions, getUpcomingBookingRequests } from "../utils"; export const load: PageServerLoad = async ({ locals }) => { const { prisma, user } = locals; authorize(apiNames.BOOKINGS.UPDATE, user); - const bookingRequests = await prisma.bookingRequest.findMany({ - where: { - start: { - gte: dayjs().subtract(1, "week").toDate(), - }, - }, - orderBy: [{ start: "asc" }, { end: "asc" }, { status: "asc" }], - include: { - bookables: true, - booker: true, - }, - }); + const bookingRequests = await getUpcomingBookingRequests(prisma); + return { bookingRequests }; }; -export const actions: Actions = { - accept: async (event) => { - const { request, locals } = event; - const { prisma, user } = locals; - const formData = await request.formData(); - const id = formData.get("id"); - if (id && typeof id === "string") { - await prisma.bookingRequest.update({ - where: { id }, - data: { - status: "ACCEPTED", - }, - }); - const request = await prisma.bookingRequest.findFirst({ - where: { - id, - }, - select: { - bookerId: true, - event: true, - }, - }); - if (request && request.bookerId != null && user && user.memberId) { - sendNotification({ - title: "Booking request accepted", - message: `Your booking request for ${request.event} has been accepted`, - type: NotificationType.BOOKING_REQUEST, - link: "/booking", - memberIds: [request.bookerId], - fromMemberId: user.memberId, - }); - } - } - }, - reject: async (event) => { - const { request, locals } = event; - const { prisma, user } = locals; - const formData = await request.formData(); - const id = formData.get("id"); - if (id && typeof id === "string") { - await prisma.bookingRequest.update({ - where: { id }, - data: { - status: "DENIED", - }, - }); - const request = await prisma.bookingRequest.findFirst({ - where: { - id, - }, - select: { - bookerId: true, - event: true, - }, - }); - if (request && request.bookerId != null && user && user.memberId) { - sendNotification({ - title: "Booking request denied", - message: `Your booking request for ${request.event} has been denied`, - type: NotificationType.BOOKING_REQUEST, - link: "/booking", - memberIds: [request.bookerId], - fromMemberId: user.memberId, - }); - } - } - }, -}; +export { actions }; diff --git a/src/routes/(app)/booking/admin/+page.svelte b/src/routes/(app)/booking/admin/+page.svelte index 950028d34..f0f9c3f1f 100644 --- a/src/routes/(app)/booking/admin/+page.svelte +++ b/src/routes/(app)/booking/admin/+page.svelte @@ -1,6 +1,6 @@ + + + + + + diff --git a/src/routes/(app)/booking/create/+page.server.ts b/src/routes/(app)/booking/create/+page.server.ts index 5696b30fc..c7b8814e2 100644 --- a/src/routes/(app)/booking/create/+page.server.ts +++ b/src/routes/(app)/booking/create/+page.server.ts @@ -5,6 +5,13 @@ import { redirect } from "$lib/utils/redirect"; import * as m from "$paraglide/messages"; import { bookingSchema } from "../schema"; import dayjs from "dayjs"; +import { + type Bookable, + type BookingRequest, + type PrismaClient, +} from "@prisma/client"; +import sendNotification from "$lib/utils/notifications"; +import { NotificationType } from "$lib/utils/notifications/types"; export const load = async ({ locals }) => { const { prisma } = locals; @@ -26,6 +33,41 @@ export const load = async ({ locals }) => { return { bookables, bookingRequests, form }; }; +const sendNotificationToKM = async ( + bookingRequest: BookingRequest & { bookables: Bookable[] }, + prisma: PrismaClient, +) => { + const kallarMastare = await prisma.member.findFirstOrThrow({ + where: { + mandates: { + some: { + positionId: "dsek.km.mastare", + startDate: { lte: new Date() }, + endDate: { gte: new Date() }, + }, + }, + }, + }); + + const booker = (await prisma.member.findUnique({ + where: { + id: bookingRequest.bookerId ?? undefined, + }, + })) ?? { firstName: "Unknown", lastName: "" }; + + const bookablesString = bookingRequest.bookables + .map((bookable) => bookable.nameEn) + .join(", "); + + await sendNotification({ + title: `New booking request: ${bookingRequest.event}`, + message: `${booker.firstName} ${booker.lastName} wants to book '${bookablesString}' from ${dayjs(bookingRequest.start).format("DD/MM HH:mm")} until ${dayjs(bookingRequest.end).format("DD/MM HH:mm")}.`, + type: NotificationType.BOOKING_REQUEST, + link: `/booking/admin/${bookingRequest.id}`, + memberIds: [kallarMastare.id], + }); +}; + export const actions = { default: async (event) => { const { request, locals } = event; @@ -35,7 +77,7 @@ export const actions = { if (!form.valid) return fail(400, { form }); const { start, end, name, bookables } = form.data; - await prisma.bookingRequest.create({ + const createdRequest = await prisma.bookingRequest.create({ data: { bookerId: user?.memberId, start: new Date(start), @@ -48,6 +90,11 @@ export const actions = { }, status: "PENDING", }, + include: { bookables: true }, + }); + + await sendNotificationToKM(createdRequest, prisma).catch((e) => { + console.log("Failed sending notifications to KM: ", e); }); throw redirect( diff --git a/src/routes/(app)/booking/utils.ts b/src/routes/(app)/booking/utils.ts new file mode 100644 index 000000000..2f9a2dfa3 --- /dev/null +++ b/src/routes/(app)/booking/utils.ts @@ -0,0 +1,104 @@ +import sendNotification from "$lib/utils/notifications"; +import { NotificationType } from "$lib/utils/notifications/types"; +import { error, type RequestEvent } from "@sveltejs/kit"; +import type { Actions } from "./$types"; +import dayjs from "dayjs"; +import type { Bookable, BookingRequest, PrismaClient } from "@prisma/client"; +import { superValidate } from "sveltekit-superforms/server"; +import { zod } from "sveltekit-superforms/adapters"; +import { bookingSchema } from "./schema"; +import * as m from "$paraglide/messages"; + +export const actions: Actions = { + accept: async (event: RequestEvent) => { + await performAction(event, true); + }, + reject: async (event: RequestEvent) => { + await performAction(event, false); + }, +}; + +export async function getUpcomingBookingRequests(prisma: PrismaClient) { + return prisma.bookingRequest.findMany({ + where: { + start: { + gte: dayjs().subtract(1, "week").toDate(), + }, + }, + orderBy: [{ start: "asc" }, { end: "asc" }, { status: "asc" }], + include: { + bookables: true, + booker: true, + }, + }); +} + +export async function getBookingRequestOrThrow( + prisma: PrismaClient, + id: string, +) { + return prisma.bookingRequest + .findUniqueOrThrow({ + where: { id }, + include: { bookables: true }, + }) + .catch(() => { + throw error(404, m.booking_errors_notFound()); + }); +} + +export async function getSuperValidatedForm( + bookingRequest: BookingRequest & { bookables: Bookable[] }, +) { + const initialData = { + name: bookingRequest.event ?? undefined, + start: bookingRequest.start + ? dayjs(bookingRequest.start).format("YYYY-MM-DDTHH:mm") + : undefined, + end: bookingRequest.end + ? dayjs(bookingRequest.end).format("YYYY-MM-DDTHH:mm") + : undefined, + bookables: bookingRequest.bookables?.map((bookable) => bookable.id), + }; + return await superValidate(initialData, zod(bookingSchema)); +} + +async function performAction(event: RequestEvent, accepted: boolean) { + const { request, locals } = event; + const { prisma, user } = locals; + const formData = await request.formData(); + const id = formData.get("id"); + const status = accepted ? "ACCEPTED" : "DENIED"; + + if (id && typeof id === "string") { + await prisma.bookingRequest.update({ + where: { + id, + }, + data: { + status, + }, + }); + + const request = await prisma.bookingRequest.findFirst({ + where: { + id, + }, + select: { + bookerId: true, + event: true, + }, + }); + + if (request && request.bookerId != null && user && user.memberId) { + sendNotification({ + title: `Booking request ${status.toLowerCase()}`, + message: `Your booking request for ${request.event} has been ${status.toLowerCase()}`, + type: NotificationType.BOOKING_REQUEST, + link: `/booking`, + memberIds: [request.bookerId], + fromMemberId: user.memberId, + }); + } + } +} diff --git a/src/translations/en.json b/src/translations/en.json index 8965f25cd..1e2dfaf78 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -763,6 +763,7 @@ "nollning_wikia_literature_body": "Facebook groupo where a lot of second-hand course literature is being sold by older students, it's a private group but everyone is allowed to join, just ask a phadder to approve you.", "members_errors_tooLargePicture": "Profile picture too big, max size is {size}.", "nollning_events_ticketCTA": "Ticket/Register", + "booking_reviewBooking": "Review booking", "home_volunteer": "Volunteer", "committees_cafe_openinghours": "Opening hours", "committees_cafe_thecafe": "The Cafe", diff --git a/src/translations/sv.json b/src/translations/sv.json index 031325a96..046f47bf7 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -30,8 +30,8 @@ "access": "Åtkomst", "doors": "Dörrar", "emailAliases": "Mejlaliaser", - "alerts": "Globala meddelanden", "linkShortener": "Länkförkortare", + "alerts": "Globala meddelanden", "adminSettings": "Admininställningar", "files": "Filer", "phadderGroups": "Phaddergrupper", @@ -759,6 +759,7 @@ "nollning_wikia_literature_body": "Facebookgrupp där mycket kurslitteratur säljs av äldre studenter, det är en privat grupp men alla får gå med, be en phadder godkänna dig.", "members_errors_tooLargePicture": "Profilbild är för stor, den får max vara {size}.", "nollning_events_ticketCTA": "Biljett/Anmälan", + "booking_reviewBooking": "Granska bokning", "home_volunteer": "Funktionär", "committees_cafe_openinghours": "Öppettider", "committees_cafe_thecafe": "Caféet",