Send notifications for appointment requests via email

This commit is contained in:
Karl Ludwig Weise
2026-05-29 22:20:58 +02:00
parent 0a0a6dca95
commit dfabcc05fc
6 changed files with 110 additions and 3 deletions
+6
View File
@@ -1034,6 +1034,12 @@
"hint": "Dieser Link ist nur {expirationMinutes} Minuten gültig und kann nur einmal verwendet werden.", "hint": "Dieser Link ist nur {expirationMinutes} Minuten gültig und kann nur einmal verwendet werden.",
"reason": "Du erhältst diese E-Mail, weil jemand für Deine E-Mail Adresse ein Konto registriert hat." "reason": "Du erhältst diese E-Mail, weil jemand für Deine E-Mail Adresse ein Konto registriert hat."
}, },
"notification": {
"subject": "Aktivität in Deinem Terminbuchungsportal",
"introduction": "in Deinem Terminbuchungsportal gab es eine neue Aktivität. Bitte logge Dich ein, um die Details zu sehen.",
"action": "Dashboard öffnen",
"reason": "Du erhältst diese E-Mail, weil Du für einen Kanal Benachrichtigungen aktiviert hast."
},
"pinReset": { "pinReset": {
"subject": "PIN erfolgreich geändert", "subject": "PIN erfolgreich geändert",
"introduction": "Sie haben erfolgreich Ihre PIN beim Terminbuchungsportal von {tenant} geändert und können sich ab sofort mit Ihrer PIN anmelden.", "introduction": "Sie haben erfolgreich Ihre PIN beim Terminbuchungsportal von {tenant} geändert und können sich ab sofort mit Ihrer PIN anmelden.",
+6
View File
@@ -1049,6 +1049,12 @@
"action": "Login", "action": "Login",
"reason": "You are receiving this email because someone changed the PIN-Code for your account." "reason": "You are receiving this email because someone changed the PIN-Code for your account."
}, },
"notification": {
"subject": "Activity in your appointment booking platform",
"introduction": "There is new activity in your appointment booking platform. Please log in to view details.",
"action": "Open Dashboard",
"reason": "You are receiving this email because you have notifications enabled for a channel."
},
"userInvite": { "userInvite": {
"subject": "Confirm your E-Mail Address", "subject": "Confirm your E-Mail Address",
"introduction": "welcome our appointment booking platform. Please confirm your e-mail address.", "introduction": "welcome our appointment booking platform. Please confirm your e-mail address.",
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { setLocale } from "$i18n/runtime";
import type { SupportedLocale } from "$lib/const/locales";
import type { SelectUserEmail } from "$lib/server/email/email-service";
import EmailButton from "./components/EmailButton.svelte";
import EmailLayout from "./components/EmailLayout.svelte";
import EmailText from "./components/EmailText.svelte";
let {
locale,
user,
dashboardUrl,
}: {
locale: SupportedLocale;
user: SelectUserEmail;
dashboardUrl: string;
} = $props();
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
<EmailText variant="md">{m["emails.greeting"]({ name: user.name })}</EmailText>
<EmailText variant="md">
{m["emails.notification.introduction"]()}
</EmailText>
<EmailButton href={dashboardUrl}>{m["emails.notification.action"]()}</EmailButton>
<EmailText variant="md" color="text-light">
{m["emails.notification.reason"]()}
</EmailText>
</EmailLayout>
+32
View File
@@ -24,6 +24,7 @@ import Confirmation from "$lib/emails/Confirmation.svelte";
import PinReset from "$lib/emails/PinReset.svelte"; import PinReset from "$lib/emails/PinReset.svelte";
import UserInvite from "$lib/emails/UserInvite.svelte"; import UserInvite from "$lib/emails/UserInvite.svelte";
import { dev } from "$app/environment"; import { dev } from "$app/environment";
import Notification from "$lib/emails/Notification.svelte";
export type SelectClient = { export type SelectClient = {
email: string; email: string;
@@ -541,3 +542,34 @@ export async function sendAppointmentCancelledEmail(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendEmail(recipient as any, subject, html, text, tenant.longName); await sendEmail(recipient as any, subject, html, text, tenant.longName);
} }
/**
* Send notification email
* @param {SelectClient | SelectUser} user - Database user object or client data
* @param {SelectTenant} tenant - Tenant information for branding
* @param {SelectAppointment} appointment - Cancelled appointment details
* @param {string} [channelTitle] - Optional channel title/name
* @throws {Error} When email sending fails
* @returns {Promise<void>}
*/
export async function sendNotificationEmail(
user: SelectUser,
tenant: { domain: string; longName: string },
): Promise<void> {
// Create recipient directly for SelectClient type, use helper for SelectUser
const { recipient, locale } = await getRecipient(user);
// Generate email
const subject = m["emails.notification.subject"]();
const emailRender = render(Notification, {
props: {
locale,
user,
dashboardUrl: dev ? `http://localhost:5173/dashboard` : `https://${tenant.domain}/dashboard`,
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendEmail(recipient as any, subject, html, text, tenant.longName);
}
@@ -1,14 +1,17 @@
import { getTenantDb } from "../db"; import { centralDb, getTenantDb } from "../db";
import { import {
notification, notification,
channelStaff, channelStaff,
notificationTypes, notificationTypes,
type NotificationType, type NotificationType,
} from "../db/tenant-schema"; } from "../db/tenant-schema";
import { eq, and, desc } from "drizzle-orm"; import { user } from "$lib/server/db/central-schema";
import { eq, and, desc, inArray } from "drizzle-orm";
import logger from "$lib/logger"; import logger from "$lib/logger";
import { z } from "zod"; import { z } from "zod";
import { ValidationError, NotFoundError } from "../utils/errors"; import { ValidationError, NotFoundError } from "../utils/errors";
import { sendNotificationEmail } from "../email/email-service";
import { TenantAdminService } from "./tenant-admin-service";
const notificationCreationSchema = z.object({ const notificationCreationSchema = z.object({
channelId: z.uuid({ message: "Invalid UUID format" }), channelId: z.uuid({ message: "Invalid UUID format" }),
@@ -105,6 +108,30 @@ export class NotificationService {
.values(notificationsToCreate) .values(notificationsToCreate)
.returning({ id: notification.id }); .returning({ id: notification.id });
// Send e-mail notifications
if (request.type === "APPOINTMENT_REQUESTED") {
const userAccounts = await centralDb
.select()
.from(user)
.where(
inArray(
user.id,
notificationsToCreate.map((n) => n.staffId),
),
);
if (userAccounts.length > 0) {
const adminService = await TenantAdminService.getTenantById(this.tenantId);
const tenant = adminService.tenantData;
if (tenant) {
await Promise.all(
userAccounts.map((staff) =>
sendNotificationEmail(staff, { domain: tenant.domain, longName: tenant.longName }),
),
);
}
}
}
log.info("Created notifications for channel", { log.info("Created notifications for channel", {
channelId: request.channelId, channelId: request.channelId,
count: createdNotifications.length, count: createdNotifications.length,
@@ -47,6 +47,8 @@
> >
{m["login.action"]()} {m["login.action"]()}
</Form.Button> </Form.Button>
<Button href={resolve(ROUTES.LOGIN)} variant="link">{m["clients.login.altAction"]()}</Button> <Button href={resolve(ROUTES.DASHBOARD.MAIN)} variant="link">
{m["clients.login.altAction"]()}
</Button>
</CenteredCard.Action> </CenteredCard.Action>
</CenteredCard.Root> </CenteredCard.Root>