Merge pull request #175 from open-reception/153-book-appointment-allow-clients-to-select-if-they-want-email-notifications

Added front-end flow to make email sending optional
This commit is contained in:
Karl Ludwig Weise
2026-01-21 20:02:38 +01:00
committed by GitHub
23 changed files with 247 additions and 137 deletions
+48 -3
View File
@@ -6,7 +6,24 @@ on:
types: [opened, synchronize, reopened]
jobs:
test-and-build:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
check:
runs-on: ubuntu-latest
steps:
@@ -24,9 +41,21 @@ jobs:
- name: Run type checking
run: npm run check
tests:
runs-on: ubuntu-latest
- name: Run linting
run: npm run lint
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit -- --run
@@ -41,6 +70,22 @@ jobs:
SMTP_FROM_EMAIL: "noreply@example.com"
JWT_SECRET: "test"
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
env:
+26 -35
View File
@@ -33,7 +33,7 @@
"@eslint/compat": "^1.3.0",
"@eslint/js": "^9.29.0",
"@inlang/paraglide-js": "2.3.2",
"@internationalized/date": "^3.8.2",
"@internationalized/date": "^3.10.1",
"@lucide/svelte": "^0.544.0",
"@playwright/test": "^1.56.1",
"@sveltejs/adapter-auto": "^6.1.0",
@@ -46,7 +46,7 @@
"@types/dotenv": "^6.1.1",
"@types/node": "^24",
"@types/nodemailer": "^6.4.17",
"bits-ui": "^2.11.4",
"bits-ui": "^2.15.4",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.1",
"eslint": "^9.29.0",
@@ -1673,9 +1673,9 @@
}
},
"node_modules/@internationalized/date": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz",
"integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==",
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz",
"integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -3674,17 +3674,17 @@
"license": "MIT"
},
"node_modules/bits-ui": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.11.4.tgz",
"integrity": "sha512-OlVBJhNUMDHbIAf8oDAyPchIrU8b1S5NAMm6enMZSKx5HKcf/QPI485/BL1r4EPlv4O3m45e59hBRCETtYFdxg==",
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.15.4.tgz",
"integrity": "sha512-7H9YUfp03KOk1LVDh8wPYSRPxlZgG/GRWLNSA8QC73/8Z8ytun+DWJhIuibyFyz7A0cP/RANVcB4iDrbY8q+Og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.1",
"@floating-ui/dom": "^1.7.1",
"esm-env": "^1.1.2",
"runed": "^0.31.1",
"svelte-toolbelt": "^0.10.4",
"runed": "^0.35.1",
"svelte-toolbelt": "^0.10.6",
"tabbable": "^6.2.0"
},
"engines": {
@@ -3699,9 +3699,9 @@
}
},
"node_modules/bits-ui/node_modules/runed": {
"version": "0.31.1",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.31.1.tgz",
"integrity": "sha512-v3czcTnO+EJjiPvD4dwIqfTdHLZ8oH0zJheKqAHh9QMViY7Qb29UlAMRpX7ZtHh7AFqV60KmfxaJ9QMy+L1igQ==",
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz",
"integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte",
@@ -3709,23 +3709,31 @@
],
"license": "MIT",
"dependencies": {
"esm-env": "^1.0.0"
"dequal": "^2.0.3",
"esm-env": "^1.0.0",
"lz-string": "^1.5.0"
},
"peerDependencies": {
"@sveltejs/kit": "^2.21.0",
"svelte": "^5.7.0"
},
"peerDependenciesMeta": {
"@sveltejs/kit": {
"optional": true
}
}
},
"node_modules/bits-ui/node_modules/svelte-toolbelt": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.5.tgz",
"integrity": "sha512-8e+eWTgxw1aiLxhDE8Rb1X6AoLitqpJz+WhAul2W7W58C8KoLoJQf1TgQdFPBiCPJ0Jg5y0Zi1uyua9em4VS0w==",
"version": "0.10.6",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
"integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte"
],
"dependencies": {
"clsx": "^2.1.1",
"runed": "^0.29.0",
"runed": "^0.35.1",
"style-to-object": "^1.0.8"
},
"engines": {
@@ -3736,23 +3744,6 @@
"svelte": "^5.30.2"
}
},
"node_modules/bits-ui/node_modules/svelte-toolbelt/node_modules/runed": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.29.2.tgz",
"integrity": "sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"license": "MIT",
"dependencies": {
"esm-env": "^1.0.0"
},
"peerDependencies": {
"svelte": "^5.7.0"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+2 -2
View File
@@ -41,7 +41,7 @@
"@eslint/compat": "^1.3.0",
"@eslint/js": "^9.29.0",
"@inlang/paraglide-js": "2.3.2",
"@internationalized/date": "^3.8.2",
"@internationalized/date": "^3.10.1",
"@lucide/svelte": "^0.544.0",
"@playwright/test": "^1.56.1",
"@sveltejs/adapter-auto": "^6.1.0",
@@ -54,7 +54,7 @@
"@types/dotenv": "^6.1.1",
"@types/node": "^24",
"@types/nodemailer": "^6.4.17",
"bits-ui": "^2.11.4",
"bits-ui": "^2.15.4",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.1",
"eslint": "^9.29.0",
+2 -1
View File
@@ -844,7 +844,8 @@
"description": "Nur Sie und {name} können diese Informationen sehen. Die Daten sind Ende-zu-Ende verschlüsselt."
},
"email": {
"description": "Wir senden Ihnen eine Bestätigungs-E-Mail mit den Termindetails."
"notifyMe": "Benachrichtigen Sie mich über Änderungen per E-Mail",
"tooltip": "Wenn aktiviert, wird Ihre Mail vorübergehend im Logbuch des Mailservers auftauchen. Wenn deaktiviert müssen Sie sich auf dieser Seite Einloggen, um Änderungen einzusehen."
},
"phone": {
"description": "Format: +491234567890. {name} kontaktiert Sie ggf. telefonisch, falls es Fragen/Änderungen zu Ihrem Termin gibt.",
+2 -1
View File
@@ -853,7 +853,8 @@
"description": "Only you and {name} will be able to access this information. It is end-to-end encrypted."
},
"email": {
"description": "We will use this to send you the appointment details."
"notifyMe": "Notify me of changes by e-mail",
"tooltip": "If enabled, your e-mail address will appear temporarily in server logs of the mailserver. If disabled, you will have to login on this page to check for updates."
},
"phone": {
"description": "Format: +1234567890. {name} may contact you by phone, if there are questions or changes regarding your appointment.",
+3 -2
View File
@@ -71,6 +71,7 @@ interface EncryptedData {
export interface AppointmentData {
name: string;
email: string;
shareEmail: boolean;
phone?: string;
}
@@ -319,7 +320,7 @@ export class UnifiedAppointmentCrypto {
appointmentDate,
duration,
emailHash: this.emailHash,
clientEmail: appointmentData.email,
clientEmail: appointmentData.shareEmail ? appointmentData.email : undefined,
clientLanguage,
clientPublicKey: this.clientKeyPair?.publicKey,
privateKeyShare: await this.getPrivateKeyShare(),
@@ -336,7 +337,7 @@ export class UnifiedAppointmentCrypto {
channelId,
appointmentDate,
duration,
clientEmail: appointmentData.email,
clientEmail: appointmentData.shareEmail ? appointmentData.email : undefined,
clientLanguage,
encryptedAppointment,
};
@@ -1,31 +1,50 @@
<script lang="ts">
import { Checkbox } from "../checkbox";
import * as Popover from "$lib/components/ui/popover/index.js";
import { cn } from "$lib/utils";
import type { HTMLAttributes } from "svelte/elements";
import { Text } from "../typography";
import type { OnChangeFn } from "vaul-svelte";
import { Checkbox } from "../checkbox";
import { Text } from "../typography";
import { Info } from "@lucide/svelte/icons";
import { buttonVariants } from "../button";
let {
class: className,
value = $bindable(false),
label,
tooltip,
onCheckedChange,
...restProps
}: HTMLAttributes<HTMLButtonElement> & {
value: boolean;
label: string;
tooltip?: string;
id?: string;
onCheckedChange?: OnChangeFn<boolean>;
} = $props();
</script>
<label class={cn("flex items-start gap-2", className)}>
<Checkbox
{...restProps}
checked={value}
value={value ? "true" : "false"}
{onCheckedChange}
class="mt-0.5"
/>
<Text style="sm" class="font-normal select-none">{label}</Text>
<label class={cn("flex items-center gap-2", className)}>
<div class="flex items-start gap-2">
<Checkbox
{...restProps}
checked={value}
value={value ? "true" : "false"}
{onCheckedChange}
class="mt-0.5"
/>
<Text style="sm" class="font-normal select-none">{label}</Text>
</div>
{#if tooltip}
<Popover.Root>
<Popover.Trigger class={cn(buttonVariants({ variant: "ghost", size: "xs" }), "p-1!")}>
<Info size="sm" />
</Popover.Trigger>
<Popover.Content class="w-65 leading-1">
<Text style="xs">
{tooltip}
</Text>
</Popover.Content>
</Popover.Root>
{/if}
</label>
@@ -22,7 +22,7 @@
)}
{...restProps}
>
{cell.char}
{cell.char ? "*" : null}
{#if cell.hasFakeCaret}
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="animate-caret-blink bg-foreground h-4 w-px duration-1000"></div>
@@ -7,7 +7,13 @@
let { value = $bindable(), ...restProps }: Props = $props();
</script>
<InputOTP.Root {...restProps} maxlength={6} bind:value>
<InputOTP.Root
{...restProps}
maxlength={6}
type="password"
pushPasswordManagerStrategy="none"
bind:value
>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 3) as cell (cell)}
+5 -3
View File
@@ -1,17 +1,19 @@
import { Popover as PopoverPrimitive } from "bits-ui";
import Root from "./popover.svelte";
import Close from "./popover-close.svelte";
import Content from "./popover-content.svelte";
import Trigger from "./popover-trigger.svelte";
const Root = PopoverPrimitive.Root;
const Close = PopoverPrimitive.Close;
import Portal from "./popover-portal.svelte";
export {
Root,
Content,
Trigger,
Close,
Portal,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
Portal as PopoverPortal,
};
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
</script>
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
@@ -1,6 +1,8 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from "bits-ui";
import PopoverPortal from "./popover-portal.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
@@ -10,20 +12,20 @@
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: PopoverPrimitive.PortalProps;
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
} = $props();
</script>
<PopoverPrimitive.Portal {...portalProps}>
<PopoverPortal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...restProps}
/>
</PopoverPrimitive.Portal>
</PopoverPortal>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
<PopoverPrimitive.Portal {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
</script>
<PopoverPrimitive.Root bind:open {...restProps} />
+15 -13
View File
@@ -20,7 +20,7 @@ export interface ClientTunnelData {
appointmentDate: string;
duration: number;
emailHash: string;
clientEmail: string;
clientEmail?: string;
clientLanguage?: string;
clientPublicKey: string;
privateKeyShare: string;
@@ -437,19 +437,21 @@ export class AppointmentService {
});
// Send email notification to client (async, don't wait)
this.sendAppointmentNotification(
result.appointment.id,
clientData.channelId,
clientData.clientEmail,
clientData.clientLanguage || "de",
result.requiresConfirmation,
).catch((error) => {
log.error("Failed to send appointment notification", {
tunnelId: clientData.tunnelId,
appointmentId: result.appointment.id,
error: String(error),
if (clientData.clientEmail) {
this.sendAppointmentNotification(
result.appointment.id,
clientData.channelId,
clientData.clientEmail,
clientData.clientLanguage || "de",
result.requiresConfirmation,
).catch((error) => {
log.error("Failed to send appointment notification", {
tunnelId: clientData.tunnelId,
appointmentId: result.appointment.id,
error: String(error),
});
});
});
}
return response;
}
+1
View File
@@ -40,6 +40,7 @@ export type TPublicAppointment = {
};
data?: {
name: string;
shareEmail: boolean;
email: string;
phone?: string;
};
@@ -1,16 +1,17 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import * as Alert from "$lib/components/ui/alert";
import { CheckboxWithLabel } from "$lib/components/ui/checkbox-with-label";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { Text } from "$lib/components/ui/typography";
import { publicStore } from "$lib/stores/public";
import type { TPublicAppointment } from "$lib/types/public";
import { Lock } from "@lucide/svelte";
import { get } from "svelte/store";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { Lock } from "@lucide/svelte";
import { publicStore } from "$lib/stores/public";
import { createFormSchema } from "./schema";
import { get } from "svelte/store";
const { proceed }: { proceed: (a: Partial<TPublicAppointment>) => void } = $props();
const tenant = $derived($publicStore.tenant);
@@ -19,6 +20,7 @@
const form = superForm(
{
name: "",
shareEmail: false,
email: "",
phone: get(publicStore).tenant?.requirePhone ? "" : undefined,
},
@@ -36,6 +38,7 @@
...appointment,
data: {
name: validation.data.name,
shareEmail: validation.data.shareEmail,
email: validation.data.email,
phone: validation.data.phone,
},
@@ -74,18 +77,33 @@
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="email">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.email"]()}</Form.Label>
<Input {...props} bind:value={$formData.email} type="email" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
<Form.Description class="mt-1">
{m["public.steps.data.email.description"]()}
</Form.Description>
</Form.Field>
<div>
<Form.Field {form} name="email">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.email"]()}</Form.Label>
<Input {...props} bind:value={$formData.email} type="email" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="shareEmail">
<Form.Control>
{#snippet children({ props })}
<CheckboxWithLabel
{...props}
bind:value={$formData.shareEmail}
label={m["public.steps.data.email.notifyMe"]()}
tooltip={m["public.steps.data.email.tooltip"]()}
onCheckedChange={(v) => {
$formData.shareEmail = v;
}}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</div>
<Form.Field {form} name="phone">
<Form.Control>
{#snippet children({ props })}
+10 -13
View File
@@ -5,20 +5,20 @@
import * as Form from "$lib/components/ui/form";
import type { EventReporter } from "$lib/components/ui/form/form-root.svelte";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Passkey } from "$lib/components/ui/passkey";
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
import { Text } from "$lib/components/ui/typography";
import { ROUTES } from "$lib/const/routes";
import logger from "$lib/logger";
import { auth } from "$lib/stores/auth";
import { arrayBufferToBase64, fetchChallenge, getCredential } from "$lib/utils/passkey";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import { writable, type Writable } from "svelte/store";
import { type Infer, superForm, type SuperValidated } from "sveltekit-superforms";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { baseSchema, formSchema, type FormSchema } from "./schema";
import { onMount } from "svelte";
import { Passkey } from "$lib/components/ui/passkey";
import { Text } from "$lib/components/ui/typography";
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
import { arrayBufferToBase64, fetchChallenge, getCredential } from "$lib/utils/passkey";
import { Label } from "$lib/components/ui/label";
import { auth } from "$lib/stores/auth";
import logger from "$lib/logger";
import { baseSchema, formSchema } from "./schema";
let { formId, onEvent }: { formId: string; onEvent: EventReporter } = $props();
@@ -165,9 +165,6 @@
const { form: formData, enhance } = form;
const passkeyLoading: Writable<PasskeyState> = writable("initial");
type FormDataPassphrase = Extract<typeof $formData, { type: "passphrase" }>;
type FormDataPasskey = Extract<typeof $formData, { type: "passkey" }>;
</script>
<Form.Root {formId} {enhance}>
+5 -2
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { browser } from "$app/environment";
import { setLocale } from "$i18n/runtime";
import { Toaster } from "$lib/components/ui/sonner/index.js";
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { ModeWatcher, setMode } from "mode-watcher";
import "../app.css";
import { setLocale } from "$i18n/runtime";
let { data, children } = $props();
@@ -27,4 +28,6 @@
<ModeWatcher track={false} />
<Toaster richColors />
{@render children()}
<Tooltip.Provider delayDuration={0}>
{@render children()}
</Tooltip.Provider>
@@ -140,7 +140,7 @@ describe("POST /api/auth/invite", () => {
"STAFF",
"12345678-1234-4234-8234-123456789012",
"admin-id",
"de",
"en",
);
expect(vi.mocked(sendUserInviteEmail)).toHaveBeenCalled();
});
@@ -174,7 +174,7 @@ describe("POST /api/auth/invite", () => {
"STAFF",
"12345678-1234-4234-8234-123456789012",
"admin-id",
"de",
"en",
);
expect(vi.mocked(sendUserInviteEmail)).toHaveBeenCalledWith(
"user@example.com",
@@ -182,7 +182,7 @@ describe("POST /api/auth/invite", () => {
mockTenant,
"STAFF",
expect.stringContaining("invite-code-123"),
"de",
"en",
);
});
@@ -8,7 +8,7 @@ import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
const requestSchema = z.object({
clientEmail: z.string().email().optional(),
clientEmail: z.email().optional(),
clientLanguage: z.string().optional().default("en"),
});
@@ -27,7 +27,7 @@ const requestSchema = z.object({
agentId: z.string(),
appointmentDate: z.string(),
duration: z.number().int().positive(),
clientEmail: z.string().email(),
clientEmail: z.email().optional(),
clientLanguage: z.string().optional().default("en"),
encryptedAppointment: z.object({
encryptedPayload: z.string(),
@@ -121,7 +121,6 @@ registerOpenAPIRoute("/tenants/{id}/appointments/add-to-tunnel", "POST", {
"tunnelId",
"channelId",
"appointmentDate",
"clientEmail",
"encryptedAppointment",
],
},
@@ -304,12 +303,6 @@ export const POST: RequestHandler = async ({ request, params }) => {
if (!tenant) {
logger.warn("Cannot send email: Tenant not found", { tenantId });
} else {
// Create client data object from request
const clientData = {
email: validatedData.clientEmail,
language: validatedData.clientLanguage,
};
// Get channel title for the email
const channelTitle = await getChannelTitle(
tenantId,
@@ -318,20 +311,28 @@ export const POST: RequestHandler = async ({ request, params }) => {
);
// Send appropriate email based on whether confirmation is required
if (requiresConfirmation) {
await sendAppointmentRequestEmail(clientData, tenant, result, channelTitle);
logger.info("Appointment request email sent", {
tunnelId: validatedData.tunnelId,
appointmentId: result.id,
tenantId,
});
} else {
await sendAppointmentCreatedEmail(clientData, tenant, result, channelTitle);
logger.info("Appointment confirmation email sent", {
tunnelId: validatedData.tunnelId,
appointmentId: result.id,
tenantId,
});
if (validatedData.clientEmail) {
// Create client data object from request
const clientData = {
email: validatedData.clientEmail,
language: validatedData.clientLanguage,
};
if (requiresConfirmation) {
await sendAppointmentRequestEmail(clientData, tenant, result, channelTitle);
logger.info("Appointment request email sent", {
tunnelId: validatedData.tunnelId,
appointmentId: result.id,
tenantId,
});
} else {
await sendAppointmentCreatedEmail(clientData, tenant, result, channelTitle);
logger.info("Appointment confirmation email sent", {
tunnelId: validatedData.tunnelId,
appointmentId: result.id,
tenantId,
});
}
}
}
} catch (emailError) {
@@ -12,7 +12,7 @@ const requestSchema = z.object({
appointmentDate: z.string(),
duration: z.number().int().positive(),
emailHash: z.string(),
clientEmail: z.string().email(),
clientEmail: z.email().optional(),
clientLanguage: z.string().optional().default("en"),
clientPublicKey: z.string(),
privateKeyShare: z.string(),
@@ -74,7 +74,7 @@ registerOpenAPIRoute("/tenants/{id}/appointments/create-new-client", "POST", {
clientEmail: {
type: "string",
format: "email",
description: "Client email address for sending confirmation",
description: "Client email address for sending confirmation. Optional.",
example: "client@example.com",
},
clientLanguage: {
@@ -138,7 +138,6 @@ registerOpenAPIRoute("/tenants/{id}/appointments/create-new-client", "POST", {
"channelId",
"appointmentDate",
"emailHash",
"clientEmail",
"clientPublicKey",
"privateKeyShare",
"encryptedAppointment",