Merge branch 'main' of github.com:open-reception/appointment-booking-software into 149-allow-creation-of-appointments-through-staff-members

This commit is contained in:
Hendrik Belitz
2026-01-22 11:42:45 +01:00
88 changed files with 5281 additions and 799 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:
+491 -641
View File
File diff suppressed because it is too large Load Diff
+22 -22
View File
@@ -38,38 +38,38 @@
"db:clean": "npm run docker:dev:down && npm run db:drop && npm run docker:dev:up"
},
"devDependencies": {
"@eslint/compat": "^1.3.0",
"@eslint/js": "^9.29.0",
"@inlang/paraglide-js": "2.3.2",
"@internationalized/date": "^3.8.2",
"@lucide/svelte": "^0.544.0",
"@playwright/test": "^1.56.1",
"@sveltejs/adapter-auto": "^6.1.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^5.1.0",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.10",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/svelte": "^5.2.8",
"@eslint/compat": "^2.0.1",
"@eslint/js": "^9.39.2",
"@inlang/paraglide-js": "2.9.1",
"@internationalized/date": "^3.10.1",
"@lucide/svelte": "^0.562.0",
"@playwright/test": "^1.57.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.50.0",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@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",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.9.3",
"eslint-plugin-svelte": "^3.14.0",
"formsnap": "^2.0.1",
"globals": "^16.2.0",
"jsdom": "^26.1.0",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.13",
"svelte": "^5.34.7",
"svelte-check": "^4.2.2",
"svelte-sonner": "^1.0.5",
"sveltekit-superforms": "^2.28.0",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.46.4",
"svelte-check": "^4.3.5",
"svelte-sonner": "^1.0.7",
"sveltekit-superforms": "^2.29.1",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.1",
"tailwindcss": "^4.1.10",
@@ -84,7 +84,7 @@
"@noble/hashes": "^1.8.0",
"@noble/post-quantum": "^0.4.1",
"@simplewebauthn/server": "^11.0.0",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/adapter-node": "^5.5.1",
"argon2": "^0.43.0",
"argon2-browser": "^1.18.0",
"cropperjs": "^2.0.1",
+19 -1
View File
@@ -1 +1,19 @@
cache
# IF GIT SHOWED THAT THIS FILE CHANGED
#
# 1. RUN THE FOLLOWING COMMAND
#
# ---
# git rm --cached '**/*.inlang/.gitignore'
# ---
#
# 2. COMMIT THE CHANGE
#
# ---
# git commit -m "fix: remove tracked .gitignore from inlang project"
# ---
#
# Inlang handles the gitignore itself starting with version ^2.5.
#
# everything is ignored except settings.json
*
!settings.json
+8 -1
View File
@@ -70,6 +70,7 @@
"pinInsecure": "Diese PIN ist zu unsicher"
},
"passkey": "Passkey",
"salutation": "Anrede",
"name": "Name",
"phone": "Telefonnummer",
"pin": "PIN",
@@ -844,7 +845,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.",
@@ -938,6 +940,11 @@
"introduction": "Ihr Termin wurde angefragt. Sie werden benachrichtigt, sobald der Termin bestätigt wurde.",
"reason": "Sie erhalten diese E-Mail, weil jemand mit Ihrer E-Mail Adresse einen Termin bei angefragt hat."
},
"appointmentRejected": {
"subject": "Ihre Anfrage für {channel} bei {tenant} wurde abgelehnt",
"introduction": "Ihr Termin wurde abgelehnt.",
"reason": "Sie erhalten diese E-Mail, weil jemand mit Ihrer E-Mail Adresse einen Termin bei angefragt hat."
},
"confirmation": {
"subject": "E-Mail Adresse bestätigen",
"introduction": "willkommen bei unserem Terminbuchungsportal. Bitte bestätige Deine E-Mail Adresse.",
+8 -1
View File
@@ -86,6 +86,7 @@
},
"noPassAtAll": "Either passphrase or passkey is required",
"passkey": "Passkey",
"salutation": "Salutation",
"name": "Name",
"phone": "Phone Number",
"pin": "PIN",
@@ -853,7 +854,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.",
@@ -942,6 +944,11 @@
"action": "Cancel appointment",
"reason": "You are receiving this email because someone booked an appointment with your e-mail address."
},
"appointmentRejected": {
"subject": "Your request for {channel} at {tenant} was rejected",
"introduction": "Your appointment was rejected.",
"reason": "You are receiving this email because someone requested an appointment with your e-mail address."
},
"appointmentRequest": {
"subject": "{channel} with {tenant} requested",
"introduction": "your appointment was requested. You will be notified once it is confirmed.",
+27
View File
@@ -3,6 +3,27 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { handle } from "./hooks.server";
import { mockCookies } from "$lib/tests/const";
// Mock the sequence function to avoid the request store issue
vi.mock("@sveltejs/kit/hooks", () => ({
sequence: (...handlers: any[]) => {
// Return a simple sequential handler that doesn't require request store
return async ({ event, resolve }: any) => {
let currentResolve = resolve;
// Chain handlers in reverse order
for (let i = handlers.length - 1; i >= 0; i--) {
const handler = handlers[i];
const previousResolve = currentResolve;
currentResolve = async (event: any) => {
return handler({ event, resolve: previousResolve });
};
}
return currentResolve(event);
};
},
}));
// Mock the startup service
vi.mock("$lib/server/services/startup-service", () => ({
StartupService: {
@@ -72,6 +93,8 @@ describe("hooks.server", () => {
isDataRequest: false,
isSubRequest: false,
platform: {} as any,
tracing: { enabled: false, root: "", current: "" },
isRemoteRequest: false,
};
};
@@ -142,6 +165,8 @@ describe("hooks.server", () => {
isDataRequest: false,
isSubRequest: false,
platform: {} as any,
tracing: { enabled: false, root: "", current: "" },
isRemoteRequest: false,
});
beforeEach(() => {
@@ -189,6 +214,8 @@ describe("hooks.server", () => {
isDataRequest: false,
isSubRequest: false,
platform: {} as any,
tracing: { enabled: false, root: "", current: "" },
isRemoteRequest: false,
});
beforeEach(() => {
+6 -2
View File
@@ -69,8 +69,10 @@ interface EncryptedData {
}
export interface AppointmentData {
salutation?: string;
name: string;
email: string;
shareEmail: boolean;
phone?: string;
}
@@ -319,7 +321,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(),
@@ -327,6 +329,7 @@ export class UnifiedAppointmentCrypto {
staffKeyShares: await this.getStaffKeyShares(tenantId),
clientKeyShare: await this.getClientKeyShare(),
clientEncryptedTunnelKey: await this.encryptTunnelKeyForClient(),
salutation: appointmentData.salutation,
}
: {
// Existing client
@@ -336,9 +339,10 @@ export class UnifiedAppointmentCrypto {
channelId,
appointmentDate,
duration,
clientEmail: appointmentData.email,
clientEmail: appointmentData.shareEmail ? appointmentData.email : undefined,
clientLanguage,
encryptedAppointment,
salutation: appointmentData.salutation,
};
const response = await fetch(endpoint, {
@@ -66,6 +66,7 @@
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isCurrentSection(item.url)} tooltipContent={item.title}>
{#snippet child({ props })}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.url} {...props}>
<item.icon />
<Text style="md" class="ml-2">{item.title}</Text>
@@ -42,6 +42,7 @@
{#if $auth.user && item.roles.includes($auth.user?.role)}
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isCurrentSection(item.url)} tooltipContent={item.title}>
<!-- eslint-disable svelte/no-navigation-without-resolve -->
{#snippet child({ props })}
<a
href={item.url}
@@ -55,6 +56,7 @@
{/if}
</a>
{/snippet}
<!-- eslint-enable svelte/no-navigation-without-resolve -->
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
@@ -12,6 +12,7 @@
import { nameToAvatarFallback } from "$lib/utils/name";
import { LanguageSwitch } from "$lib/components/templates/language-switch";
import { m } from "$i18n/messages";
import { resolve } from "$app/paths";
const sidebar = useSidebar();
</script>
@@ -61,13 +62,13 @@
<DropdownMenu.Separator />
<DropdownMenu.Group>
<LanguageSwitch class="w-full" triggerClass="w-[100%] [&>svg]:ml-auto" />
<DropdownMenu.Item onclick={() => goto(ROUTES.DASHBOARD.ACCOUNT.MAIN)}>
<DropdownMenu.Item onclick={() => goto(resolve(ROUTES.DASHBOARD.ACCOUNT.MAIN))}>
<AccountIcon />
{m["nav.account"]()}
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => goto(ROUTES.LOGOUT)}>
<DropdownMenu.Item onclick={() => goto(resolve(ROUTES.LOGOUT))}>
<LogOutIcon />
{m["nav.logout"]()}
</DropdownMenu.Item>
@@ -14,6 +14,7 @@
import UnknownTenantIcon from "@lucide/svelte/icons/landmark";
import Loader from "@lucide/svelte/icons/loader-2";
import { cn } from "$lib/utils";
import { resolve } from "$app/paths";
const sidebar = useSidebar();
@@ -116,7 +117,10 @@
{/each}
{#if $tenants?.tenants.length > maxTenantsToShow}
<DropdownMenu.Separator />
<DropdownMenu.Item class="gap-2 p-2" onclick={() => goto(ROUTES.DASHBOARD.TENANTS)}>
<DropdownMenu.Item
class="gap-2 p-2"
onclick={() => goto(resolve(ROUTES.DASHBOARD.TENANTS))}
>
<div
class="flex size-6 items-center justify-center rounded-md border bg-transparent"
>
@@ -33,6 +33,7 @@
size?: VariantProps<typeof variants>["size"];
} = $props();
// svelte-ignore state_referenced_locally
const isSmaller = size === "sm";
</script>
@@ -58,6 +58,7 @@
</script>
{#if href}
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
bind:this={ref}
data-slot="button"
@@ -70,6 +71,7 @@
>
{@render children?.()}
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{:else}
<button
bind:this={ref}
@@ -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} />
@@ -5,7 +5,7 @@
export function openDialog(id: string) {
responsiveDialogs.update((state) => {
const newState = new Map(state);
const newState = new SvelteMap(state);
newState.set(id, true);
return newState;
});
@@ -13,7 +13,7 @@
export function closeDialog(id: string) {
responsiveDialogs.update((state) => {
const newState = new Map(state);
const newState = new SvelteMap(state);
if (newState.has(id)) {
newState.set(id, false);
}
@@ -34,7 +34,7 @@
import * as Drawer from "$lib/components/ui/drawer";
import { onDestroy, type Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import { MediaQuery } from "svelte/reactivity";
import { MediaQuery, SvelteMap } from "svelte/reactivity";
import { HorizontalPagePadding } from "../page";
import { ScrollArea } from "../scroll-area";
import { cn } from "$lib/utils";
@@ -80,7 +80,7 @@
onDestroy(() => {
responsiveDialogs.update((state) => {
const newState = new Map(state);
const newState = new SvelteMap(state);
newState.delete(id);
return newState;
});
@@ -77,6 +77,7 @@
return segments;
}
// svelte-ignore state_referenced_locally
const segments = processTranslation(translation, interpolations);
</script>
+1 -1
View File
@@ -28,4 +28,4 @@ export const ROUTES = {
CHANGE_PASSPHRASE: "/dashboard/account/change-passphrase",
},
},
};
} as const;
+3 -1
View File
@@ -35,7 +35,9 @@
cancelUrl: string;
} = $props();
setLocale(locale);
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { setLocale } from "$i18n/runtime";
import type { SupportedLocale } from "$lib/const/locales";
import type { SelectTenant } from "$lib/server/db/central-schema";
import { type SelectAppointment } from "$lib/server/db/tenant-schema";
import type { SelectClient } from "$lib/server/email/email-service";
import EmailHeadline from "./components/EmailHeadline.svelte";
import EmailLayout from "./components/EmailLayout.svelte";
import EmailText from "./components/EmailText.svelte";
import { renderAppointmentDate, renderAppointmentTime } from "./utils";
let {
locale,
user,
tenant,
channel,
appointment,
address,
}: {
locale: SupportedLocale;
user: SelectClient;
tenant: SelectTenant;
channel: string;
appointment: SelectAppointment & { agentName: string };
address: {
street: string;
number: string;
additionalAddressInfo?: string;
zip: string;
city: string;
};
} = $props();
setLocale(locale);
</script>
<EmailLayout>
<EmailText variant="md">{m["emails.greeting"]({ name: user.email })}</EmailText>
<EmailText variant="md">
{m["emails.appointmentRejected.introduction"]()}
</EmailText>
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
{address.street}
{address.number}<br />
{#if address.additionalAddressInfo}{address.additionalAddressInfo}<br />{/if}
{address.zip}
{address.city}
</EmailText>
<EmailText variant="md" color="text-light">
{m["emails.appointmentRejected.reason"]()}
</EmailText>
</EmailLayout>
+3 -1
View File
@@ -35,7 +35,9 @@
cancelUrl: string;
} = $props();
setLocale(locale);
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
+3 -1
View File
@@ -32,7 +32,9 @@
};
} = $props();
setLocale(locale);
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
+3 -1
View File
@@ -19,7 +19,9 @@
expirationMinutes: number;
} = $props();
setLocale(locale);
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
+3 -1
View File
@@ -20,7 +20,9 @@
loginUrl: string;
} = $props();
setLocale(locale);
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
+3 -1
View File
@@ -22,7 +22,9 @@
expirationMinutes: number;
} = $props();
setLocale(locale);
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
@@ -3,6 +3,7 @@
</script>
<div class="button-container">
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a class="button" {href}>{@render children?.()}</a>
</div>
+5 -1
View File
@@ -26,7 +26,11 @@ export const appointmentStatusEnum = pgEnum("appointment_status", [
"NO_SHOW",
]);
export const notificationTypes = ["APPOINTMENT_CONFIRMED", "APPOINTMENT_CANCELED"] as const;
export const notificationTypes = [
"APPOINTMENT_CONFIRMED",
"APPOINTMENT_CANCELLED",
"APPOINTMENT_REQUESTED",
] as const;
export type NotificationType = (typeof notificationTypes)[number];
export const notificationTypeEnum = pgEnum("notification_type", notificationTypes);
+76
View File
@@ -14,6 +14,7 @@ import { setLocale } from "$i18n/runtime";
import { m } from "$i18n/messages";
import { render } from "svelte/server";
import AppointmentBooked from "$lib/emails/AppointmentBooked.svelte";
import AppointmentRejected from "$lib/emails/AppointmentRejected.svelte";
import { htmlToText, renderOutputToHtml } from "$lib/emails/utils";
import { AgentService } from "../services/agent-service";
import { TenantService } from "../db/tenant-service";
@@ -232,6 +233,50 @@ const getAddressFromTenant = async (tenantId: string) => {
};
};
/**
* Send appointment rejection email for newly created appointments
* @param {SelectClient | SelectUser} user - Database user object or client data
* @param {SelectTenant} tenant - Tenant information for branding
* @param {SelectAppointment} appointment - Appointment details
* @param {string} [channelTitle] - Optional channel title/name
* @param {string} [cancelUrl] - Optional URL to cancel appointment
* @throws {Error} When email sending fails
* @returns {Promise<void>}
*/
export async function sendAppointmentRejectedEmail(
user: SelectClient | SelectUser,
tenant: SelectTenant,
appointment: SelectAppointment,
channelTitle?: string,
): Promise<void> {
// Create recipient directly for SelectClient type, use helper for SelectUser
// Set language
const agentService = await AgentService.forTenant(tenant.id);
const agent = await agentService.getAgentById(appointment.agentId);
const { recipient, locale } = await getRecipient(user);
// Generate email
const subject = m["emails.appointmentRejected.subject"]({
channel: channelTitle || appointment.channelId,
tenant: tenant.longName,
});
const emailRender = render(AppointmentRejected, {
props: {
locale,
channel: channelTitle || appointment.channelId,
user,
tenant,
appointment: { ...appointment, agentName: agent?.name ?? "---" },
address: await getAddressFromTenant(tenant.id),
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
await sendEmail(recipient, subject, html, text);
}
/**
* Send appointment confirmation email for newly created appointments
* @param {SelectClient | SelectUser} user - Database user object or client data
@@ -467,3 +512,34 @@ export async function sendUserInviteEmail(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendEmail(recipient as any, subject, html, text);
}
/**
* Send appointment cancellation 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 sendAppointmentCancelledEmail(
user: SelectClient | SelectUser,
tenant: SelectTenant,
appointment: SelectAppointment,
channelTitle?: string,
): Promise<void> {
// Create recipient directly for SelectClient type, use helper for SelectUser
const recipient: EmailRecipient =
"email" in user && typeof user.email === "string" && "language" in user && !("name" in user)
? { email: user.email, language: user.language }
: createEmailRecipient(user);
const language = (recipient.language as Language) || "en";
const subject = language === "en" ? "Appointment Cancelled" : "Termin storniert";
await sendTemplatedEmail("appointment-cancelled", recipient, subject, language, tenant, {
appointment,
appointmentDate: appointment.appointmentDate,
title: channelTitle || appointment.channelId,
});
}
+1
View File
@@ -18,6 +18,7 @@ export type EmailTemplateType =
| "appointment-created" // Appointment confirmed (no staff confirmation needed)
| "appointment-request" // Appointment requested (requires staff confirmation)
| "appointment-updated"
| "appointment-cancelled" // Appointment cancelled by staff
| "confirmation" // Registration confirmation with one-time code
| "tenant-admin-invite" // Invitation for tenant administrator
| "user-invite"; // Invitation for new users to existing tenant
@@ -0,0 +1,116 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Termin storniert</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: {{tenant.backgroundColor}};
}
.email-container {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eee;
padding-bottom: 20px;
margin-bottom: 30px;
}
.logo {
font-size: 24px;
font-weight: bold;
color: {{tenant.primaryColor}};
}
h1 {
color: #1f2937;
font-size: 24px;
margin-bottom: 20px;
}
h2 {
color: #374151;
font-size: 20px;
margin-bottom: 15px;
}
.appointment-details {
background-color: #f8fafc;
padding: 20px;
border-radius: 6px;
border-left: 4px solid #dc2626;
margin: 20px 0;
}
.warning-box {
background-color: #fef2f2;
border-left: 4px solid #dc2626;
padding: 15px;
margin: 20px 0;
border-radius: 4px;
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 14px;
color: #6b7280;
text-align: center;
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
{{#if tenant.logo}}
<img
src="data:image/png;base64,{{tenant.logo}}"
alt="{{tenant.longName}}"
style="max-height: 60px; margin-bottom: 10px"
/>
{{/if}}
<div class="logo">{{tenant.longName}}</div>
</div>
<h1>Termin storniert</h1>
<div class="warning-box">
<strong>⚠️ Ihr Termin wurde storniert</strong>
<p style="margin-top: 10px; margin-bottom: 0">
Wir möchten Sie darüber informieren, dass Ihr Termin storniert wurde.
</p>
</div>
<h2>Termindetails</h2>
<div class="appointment-details">
<p style="margin: 0 0 10px 0"><strong>Datum:</strong> {{appointmentDate}}</p>
{{#if title}}
<p style="margin: 0 0 10px 0"><strong>Termin:</strong> {{title}}</p>
{{/if}} {{#if location}}
<p style="margin: 0 0 10px 0"><strong>Ort:</strong> {{location}}</p>
{{/if}}
</div>
<p>Bei Fragen stehen wir Ihnen gerne zur Verfügung. Sie können uns jederzeit kontaktieren.</p>
<div class="footer">
<p style="margin: 0">{{tenant.longName}}</p>
{{#if tenant.links.website}}
<p style="margin: 5px 0 0 0">
<a
href="{{tenant.links.website}}"
style="color: {{tenant.primaryColor}}; text-decoration: none"
>{{tenant.links.website}}</a
>
</p>
{{/if}}
</div>
</div>
</body>
</html>
@@ -0,0 +1,23 @@
Termin storniert - {{tenant.longName}}
⚠️ IHR TERMIN WURDE STORNIERT
Wir möchten Sie darüber informieren, dass Ihr Termin storniert wurde.
TERMINDETAILS
=============
Datum: {{appointmentDate}}
{{#if title}}
Termin: {{title}}
{{/if}}
{{#if location}}
Ort: {{location}}
{{/if}}
Bei Fragen stehen wir Ihnen gerne zur Verfügung. Sie können uns jederzeit kontaktieren.
--
{{tenant.longName}}
{{#if tenant.links.website}}
{{tenant.links.website}}
{{/if}}
@@ -0,0 +1,116 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Appointment Cancelled</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: {{tenant.backgroundColor}};
}
.email-container {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eee;
padding-bottom: 20px;
margin-bottom: 30px;
}
.logo {
font-size: 24px;
font-weight: bold;
color: {{tenant.primaryColor}};
}
h1 {
color: #1f2937;
font-size: 24px;
margin-bottom: 20px;
}
h2 {
color: #374151;
font-size: 20px;
margin-bottom: 15px;
}
.appointment-details {
background-color: #f8fafc;
padding: 20px;
border-radius: 6px;
border-left: 4px solid #dc2626;
margin: 20px 0;
}
.warning-box {
background-color: #fef2f2;
border-left: 4px solid #dc2626;
padding: 15px;
margin: 20px 0;
border-radius: 4px;
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 14px;
color: #6b7280;
text-align: center;
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
{{#if tenant.logo}}
<img
src="data:image/png;base64,{{tenant.logo}}"
alt="{{tenant.longName}}"
style="max-height: 60px; margin-bottom: 10px"
/>
{{/if}}
<div class="logo">{{tenant.longName}}</div>
</div>
<h1>Appointment Cancelled</h1>
<div class="warning-box">
<strong>⚠️ Your appointment has been cancelled</strong>
<p style="margin-top: 10px; margin-bottom: 0">
We would like to inform you that your appointment has been cancelled.
</p>
</div>
<h2>Appointment Details</h2>
<div class="appointment-details">
<p style="margin: 0 0 10px 0"><strong>Date:</strong> {{appointmentDate}}</p>
{{#if title}}
<p style="margin: 0 0 10px 0"><strong>Appointment:</strong> {{title}}</p>
{{/if}} {{#if location}}
<p style="margin: 0 0 10px 0"><strong>Location:</strong> {{location}}</p>
{{/if}}
</div>
<p>If you have any questions, please don't hesitate to contact us.</p>
<div class="footer">
<p style="margin: 0">{{tenant.longName}}</p>
{{#if tenant.links.website}}
<p style="margin: 5px 0 0 0">
<a
href="{{tenant.links.website}}"
style="color: {{tenant.primaryColor}}; text-decoration: none"
>{{tenant.links.website}}</a
>
</p>
{{/if}}
</div>
</div>
</body>
</html>
@@ -0,0 +1,23 @@
Appointment Cancelled - {{tenant.longName}}
⚠️ YOUR APPOINTMENT HAS BEEN CANCELLED
We would like to inform you that your appointment has been cancelled.
APPOINTMENT DETAILS
===================
Date: {{appointmentDate}}
{{#if title}}
Appointment: {{title}}
{{/if}}
{{#if location}}
Location: {{location}}
{{/if}}
If you have any questions, please don't hesitate to contact us.
--
{{tenant.longName}}
{{#if tenant.links.website}}
{{tenant.links.website}}
{{/if}}
@@ -11,6 +11,30 @@ vi.mock("../../db", () => ({
},
}));
vi.mock("../challenge-store", () => ({
challengeStore: {
consume: vi.fn(),
store: vi.fn(),
},
}));
vi.mock("../challenge-throttle", () => ({
challengeThrottleService: {
recordFailedAttempt: vi.fn(),
clearThrottle: vi.fn(),
},
}));
vi.mock("../notification-service", () => ({
NotificationService: {
forTenant: vi.fn().mockResolvedValue({
sendAppointmentConfirmationEmail: vi.fn().mockResolvedValue(undefined),
sendAppointmentCancellationEmail: vi.fn().mockResolvedValue(undefined),
createNotification: vi.fn().mockResolvedValue(undefined),
}),
},
}));
const mockAppointment = {
id: "appointment-123",
tunnelId: "tunnel-123",
@@ -447,4 +471,451 @@ describe("AppointmentService", () => {
expect(result).toBe(false);
});
});
describe("deleteAppointmentByStaff", () => {
it("should delete appointment and send email/notifications", async () => {
const { getTenantDb } = await import("../../db");
// Mock email service
const emailModule = await import("../../email/email-service");
const mockSendEmail = vi.fn().mockResolvedValue(undefined);
const mockGetChannelTitle = vi.fn().mockResolvedValue("Test Channel");
vi.spyOn(emailModule, "sendAppointmentCancelledEmail").mockImplementation(mockSendEmail);
vi.spyOn(emailModule, "getChannelTitle").mockImplementation(mockGetChannelTitle);
// Mock TenantAdminService
const tenantModule = await import("../tenant-admin-service");
const mockTenant = {
id: "tenant-123",
shortName: "test-clinic",
longName: "Test Clinic",
languages: ["de", "en"],
};
vi.spyOn(tenantModule.TenantAdminService, "getTenantById").mockResolvedValue({
tenantData: mockTenant,
} as any);
// Mock NotificationService
const notificationModule = await import("../notification-service");
const mockCreateNotification = vi.fn().mockResolvedValue(["notification-1"]);
vi.spyOn(notificationModule.NotificationService, "forTenant").mockResolvedValue({
createNotification: mockCreateNotification,
} as any);
const mockDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([mockAppointment]),
}),
}),
}),
delete: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
}),
};
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
const service = await AppointmentService.forTenant("tenant-123");
// Use a promise to track async operations
const deletePromise = service.deleteAppointmentByStaff(
"appointment-123",
"client@example.com",
"de",
);
await deletePromise;
expect(mockDb.delete).toHaveBeenCalled();
expect(mockGetChannelTitle).toHaveBeenCalledWith("tenant-123", "channel-123", "de");
});
it("should throw NotFoundError when appointment does not exist", async () => {
const { getTenantDb } = await import("../../db");
const mockDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
}),
};
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
const service = await AppointmentService.forTenant("tenant-123");
await expect(
service.deleteAppointmentByStaff("appointment-123", "client@example.com", "de"),
).rejects.toThrow(NotFoundError);
});
it("should use default language when not provided", async () => {
const { getTenantDb } = await import("../../db");
// Mock email service
const emailModule = await import("../../email/email-service");
const mockSendEmail = vi.fn().mockResolvedValue(undefined);
const mockGetChannelTitle = vi.fn().mockResolvedValue("Test Channel");
vi.spyOn(emailModule, "sendAppointmentCancelledEmail").mockImplementation(mockSendEmail);
vi.spyOn(emailModule, "getChannelTitle").mockImplementation(mockGetChannelTitle);
// Mock TenantAdminService
const tenantModule = await import("../tenant-admin-service");
const mockTenant = {
id: "tenant-123",
shortName: "test-clinic",
longName: "Test Clinic",
languages: ["de", "en"],
};
vi.spyOn(tenantModule.TenantAdminService, "getTenantById").mockResolvedValue({
tenantData: mockTenant,
} as any);
// Mock NotificationService
const notificationModule = await import("../notification-service");
const mockCreateNotification = vi.fn().mockResolvedValue(["notification-1"]);
vi.spyOn(notificationModule.NotificationService, "forTenant").mockResolvedValue({
createNotification: mockCreateNotification,
} as any);
const mockDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([mockAppointment]),
}),
}),
}),
delete: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
}),
};
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
const service = await AppointmentService.forTenant("tenant-123");
// Use a promise to track async operations
const deletePromise = service.deleteAppointmentByStaff(
"appointment-123",
"client@example.com",
);
await deletePromise;
expect(mockGetChannelTitle).toHaveBeenCalledWith("tenant-123", "channel-123", "de");
});
});
describe("getFutureAppointmentsByTunnelId", () => {
it("should return future appointments for a client tunnel", async () => {
const { getTenantDb } = await import("../../db");
const futureDate1 = new Date("2025-02-15T10:00:00Z");
const futureDate2 = new Date("2025-03-20T14:30:00Z");
const mockFutureAppointments = [
{
id: "appointment-1",
appointmentDate: futureDate1,
status: "CONFIRMED",
channelId: "channel-1",
encryptedPayload: "encrypted-1",
iv: "iv-1",
authTag: "auth-tag-1",
},
{
id: "appointment-2",
appointmentDate: futureDate2,
status: "NEW",
channelId: "channel-2",
encryptedPayload: "encrypted-2",
iv: "iv-2",
authTag: "auth-tag-2",
},
];
const mockDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockResolvedValue(mockFutureAppointments),
}),
}),
}),
};
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
const service = await AppointmentService.forTenant("tenant-123");
const result = await service.getFutureAppointmentsByTunnelId("tunnel-123");
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
id: "appointment-1",
appointmentDate: futureDate1.toISOString(),
status: "CONFIRMED",
channelId: "channel-1",
encryptedPayload: "encrypted-1",
iv: "iv-1",
authTag: "auth-tag-1",
});
expect(result[1]).toEqual({
id: "appointment-2",
appointmentDate: futureDate2.toISOString(),
status: "NEW",
channelId: "channel-2",
encryptedPayload: "encrypted-2",
iv: "iv-2",
authTag: "auth-tag-2",
});
});
it("should return empty array when no future appointments exist", async () => {
const { getTenantDb } = await import("../../db");
const mockDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockResolvedValue([]),
}),
}),
}),
};
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
const service = await AppointmentService.forTenant("tenant-123");
const result = await service.getFutureAppointmentsByTunnelId("tunnel-123");
expect(result).toEqual([]);
});
it("should handle null encrypted fields gracefully", async () => {
const { getTenantDb } = await import("../../db");
const futureDate = new Date("2025-02-15T10:00:00Z");
const mockAppointmentsWithNulls = [
{
id: "appointment-1",
appointmentDate: futureDate,
status: "NEW",
channelId: "channel-1",
encryptedPayload: null,
iv: null,
authTag: null,
},
];
const mockDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockResolvedValue(mockAppointmentsWithNulls),
}),
}),
}),
};
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
const service = await AppointmentService.forTenant("tenant-123");
const result = await service.getFutureAppointmentsByTunnelId("tunnel-123");
expect(result).toHaveLength(1);
expect(result[0].encryptedPayload).toBe("");
expect(result[0].iv).toBe("");
expect(result[0].authTag).toBe("");
});
});
describe("deleteAppointmentByClient", () => {
it("should delete appointment after verifying challenge and ownership", async () => {
const { getTenantDb } = await import("../../db");
const { challengeStore } = await import("../challenge-store");
const { challengeThrottleService } = await import("../challenge-throttle");
const mockAppointmentDate = new Date("2025-02-15T10:00:00Z");
// Mock challenge verification
vi.mocked(challengeStore.consume).mockResolvedValue({
challenge: Buffer.from("test-challenge").toString("base64"),
emailHash: "email-hash-123",
createdAt: new Date(),
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
});
vi.mocked(challengeThrottleService.clearThrottle).mockResolvedValue();
// Mock database queries - use mockReturnValueOnce for sequential calls
const mockLimit = vi
.fn()
// First call: appointment query
.mockResolvedValueOnce([
{
id: "appointment-123",
tunnelId: "tunnel-123",
appointmentDate: mockAppointmentDate,
channelId: "channel-123",
},
])
// Second call: tunnel query
.mockResolvedValueOnce([
{
id: "tunnel-123",
emailHash: "email-hash-123",
},
]);
const mockDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: mockLimit,
}),
delete: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
}),
};
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
const service = await AppointmentService.forTenant("tenant-123");
await service.deleteAppointmentByClient(
"appointment-123",
"email-hash-123",
"challenge-123",
Buffer.from("test-challenge").toString("base64"),
);
expect(challengeStore.consume).toHaveBeenCalledWith("challenge-123", "tenant-123");
expect(challengeThrottleService.clearThrottle).toHaveBeenCalledWith("email-hash-123", "pin");
});
it("should throw NotFoundError when challenge is not found", async () => {
const { challengeStore } = await import("../challenge-store");
vi.mocked(challengeStore.consume).mockResolvedValue(null);
const service = await AppointmentService.forTenant("tenant-123");
await expect(
service.deleteAppointmentByClient(
"appointment-123",
"email-hash-123",
"invalid-challenge",
"challenge-response",
),
).rejects.toThrow(NotFoundError);
});
it("should throw ValidationError when challenge doesn't belong to client", async () => {
const { challengeStore } = await import("../challenge-store");
vi.mocked(challengeStore.consume).mockResolvedValue({
challenge: Buffer.from("test-challenge").toString("base64"),
emailHash: "different-email-hash",
createdAt: new Date(),
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
});
const service = await AppointmentService.forTenant("tenant-123");
await expect(
service.deleteAppointmentByClient(
"appointment-123",
"email-hash-123",
"challenge-123",
Buffer.from("test-challenge").toString("base64"),
),
).rejects.toThrow("Invalid authentication");
});
it("should throw ValidationError when challenge response is incorrect", async () => {
const { challengeStore } = await import("../challenge-store");
const { challengeThrottleService } = await import("../challenge-throttle");
vi.mocked(challengeStore.consume).mockResolvedValue({
challenge: Buffer.from("correct-challenge").toString("base64"),
emailHash: "email-hash-123",
createdAt: new Date(),
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
});
vi.mocked(challengeThrottleService.recordFailedAttempt).mockResolvedValue();
const service = await AppointmentService.forTenant("tenant-123");
await expect(
service.deleteAppointmentByClient(
"appointment-123",
"email-hash-123",
"challenge-123",
Buffer.from("wrong-challenge").toString("base64"),
),
).rejects.toThrow("Invalid challenge response");
expect(challengeThrottleService.recordFailedAttempt).toHaveBeenCalledWith(
"email-hash-123",
"pin",
);
});
it("should throw ValidationError when appointment doesn't belong to client", async () => {
const { getTenantDb } = await import("../../db");
const { challengeStore } = await import("../challenge-store");
const { challengeThrottleService } = await import("../challenge-throttle");
const mockAppointmentDate = new Date("2025-02-15T10:00:00Z");
vi.mocked(challengeStore.consume).mockResolvedValue({
challenge: Buffer.from("test-challenge").toString("base64"),
emailHash: "email-hash-123",
createdAt: new Date(),
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
});
vi.mocked(challengeThrottleService.clearThrottle).mockResolvedValue();
const mockDb = {
select: vi.fn().mockImplementation(() => ({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockImplementation(() => {
const callStack = new Error().stack || "";
if (callStack.includes("appointment")) {
return Promise.resolve([
{
id: "appointment-123",
tunnelId: "tunnel-123",
appointmentDate: mockAppointmentDate,
channelId: "channel-123",
},
]);
} else {
// Tunnel belongs to different email
return Promise.resolve([
{
id: "tunnel-123",
emailHash: "different-email-hash",
},
]);
}
}),
})),
};
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
const service = await AppointmentService.forTenant("tenant-123");
await expect(
service.deleteAppointmentByClient(
"appointment-123",
"email-hash-123",
"challenge-123",
Buffer.from("test-challenge").toString("base64"),
),
).rejects.toThrow("Appointment does not belong to this client");
});
});
});
+399 -13
View File
@@ -9,9 +9,15 @@ import type { AppointmentResponse } from "$lib/types/appointment";
import {
sendAppointmentCreatedEmail,
sendAppointmentRequestEmail,
sendAppointmentCancelledEmail,
getChannelTitle,
sendAppointmentRejectedEmail,
} from "../email/email-service";
import { TenantAdminService } from "./tenant-admin-service";
import { NotificationService } from "./notification-service";
import { challengeStore } from "./challenge-store";
import { challengeThrottleService } from "./challenge-throttle";
import { timingSafeEqual } from "node:crypto";
export interface ClientTunnelData {
tunnelId: string;
@@ -20,7 +26,7 @@ export interface ClientTunnelData {
appointmentDate: string;
duration: number;
emailHash: string;
clientEmail: string;
clientEmail?: string;
clientLanguage?: string;
clientPublicKey: string;
privateKeyShare: string;
@@ -122,6 +128,8 @@ export class AppointmentService {
appointmentId: appointment.id,
tenantId: this.tenantId,
});
} else if (appointment.status === "REJECTED") {
await sendAppointmentRejectedEmail(clientData, tenant, appointment, channelTitle);
} else {
await sendAppointmentCreatedEmail(clientData, tenant, appointment, channelTitle);
log.info("Appointment confirmation email sent", {
@@ -201,9 +209,77 @@ export class AppointmentService {
});
}
const notificationService = await NotificationService.forTenant(this.tenantId);
await notificationService.createNotification({
channelId: row.channelId,
type: "APPOINTMENT_CONFIRMED",
metaData: {
appointmentId: row.id,
},
});
return row;
}
public async denyAppointment(
id: string,
clientEmail?: string,
clientLanguage?: string,
): Promise<void> {
const log = logger.setContext("AppointmentService");
log.debug("Denying appointment by ID", { appointmentId: id, tenantId: this.tenantId });
const db = await this.getDb();
const result = await db
.update(tenantSchema.appointment)
.set({ status: "REJECTED" })
.where(and(eq(tenantSchema.appointment.id, id), eq(tenantSchema.appointment.status, "NEW")))
.returning();
if (result.length === 0) {
log.warn("Appointment not found or in wrong state", {
appointmentId: id,
state: result[0] ? result[0].status : undefined,
tenantId: this.tenantId,
});
throw new ValidationError("Appointment not found or in wrong state");
}
const row = result[0];
// Send rejection email to client if email is provided (async, don't wait)
if (clientEmail) {
this.sendAppointmentNotification(
row.id,
row.channelId,
clientEmail,
clientLanguage || "de",
false,
).catch((error) => {
log.error("Failed to send appointment rejection email", {
appointmentId: row.id,
error: String(error),
});
});
}
db.delete(tenantSchema.appointment)
.where(eq(tenantSchema.appointment.id, id))
.then(() => {
log.debug("Appointment deleted after rejection", {
appointmentId: id,
tenantId: this.tenantId,
});
})
.catch((error) => {
log.error("Failed to delete appointment after rejection", {
appointmentId: id,
error: String(error),
tenantId: this.tenantId,
});
});
}
public async cancelAppointment(id: string): Promise<SelectAppointment> {
const log = logger.setContext("AppointmentService");
log.debug("Confirming appointment by ID", { appointmentId: id, tenantId: this.tenantId });
@@ -250,6 +326,107 @@ export class AppointmentService {
return true;
}
/**
* Delete appointment by staff member
* This will:
* 1. Remove the appointment from the database
* 2. Send cancellation email to the client
* 3. Create notifications for all staff members in the channel
* @param appointmentId - The appointment ID
* @param clientEmail - The client's email address
* @param clientLanguage - The client's preferred language (optional, defaults to "de")
* @returns Promise<void>
*/
public async deleteAppointmentByStaff(
appointmentId: string,
clientEmail: string,
clientLanguage: string = "de",
): Promise<void> {
const log = logger.setContext("AppointmentService");
log.debug("Deleting appointment by staff", {
appointmentId,
clientEmail,
tenantId: this.tenantId,
});
const db = await this.getDb();
// Get appointment details before deletion
const appointmentResult = await db
.select()
.from(tenantSchema.appointment)
.where(eq(tenantSchema.appointment.id, appointmentId))
.limit(1);
if (appointmentResult.length === 0) {
log.warn("Appointment not found for deletion", {
appointmentId,
tenantId: this.tenantId,
});
throw new NotFoundError("Appointment not found");
}
const appointment = appointmentResult[0];
const channelId = appointment.channelId;
// Delete the appointment
await db.delete(tenantSchema.appointment).where(eq(tenantSchema.appointment.id, appointmentId));
log.debug("Appointment deleted from database", { appointmentId, tenantId: this.tenantId });
// Get tenant and channel information for email and notifications
const tenantService = await TenantAdminService.getTenantById(this.tenantId);
const tenant = tenantService.tenantData;
if (!tenant) {
log.error("Tenant not found", { tenantId: this.tenantId });
throw new InternalError("Tenant not found");
}
// Get channel title
const channelTitle = await getChannelTitle(this.tenantId, channelId, clientLanguage);
// Send cancellation email to client (async, don't wait)
const clientData = {
email: clientEmail,
language: clientLanguage,
};
sendAppointmentCancelledEmail(clientData, tenant, appointment, channelTitle).catch((error) => {
log.error("Failed to send appointment cancellation email", {
appointmentId,
clientEmail,
error: String(error),
});
});
// Create notifications for all staff members in the channel
const notificationService = await NotificationService.forTenant(this.tenantId);
// Create notifications (async, don't wait)
notificationService
.createNotification({
channelId,
type: "APPOINTMENT_CANCELLED",
metaData: {
appointmentId,
},
})
.catch((error) => {
log.error("Failed to create channel notifications", {
appointmentId,
channelId,
error: String(error),
});
});
log.info("Appointment deleted by staff successfully", {
appointmentId,
channelId,
tenantId: this.tenantId,
});
}
/**
* Get all client tunnels for the tenant
*/
@@ -522,6 +699,17 @@ export class AppointmentService {
updatedAt: tenantSchema.appointment.updatedAt,
});
if (initialStatus === "NEW") {
const notificationService = await NotificationService.forTenant(this.tenantId);
await notificationService.createNotification({
channelId: clientData.channelId,
type: "APPOINTMENT_REQUESTED",
metaData: {
appointmentId: appointmentResult[0].id,
},
});
}
if (appointmentResult.length === 0) {
throw new InternalError("Failed to create appointment");
}
@@ -544,19 +732,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;
}
@@ -654,6 +844,202 @@ export class AppointmentService {
return result;
}
/**
* Get future appointments for a client by tunnel ID
*/
public async getFutureAppointmentsByTunnelId(tunnelId: string): Promise<
Array<{
id: string;
appointmentDate: string;
status: string;
channelId: string;
encryptedPayload: string;
iv: string;
authTag: string;
}>
> {
const log = logger.setContext("AppointmentService");
log.debug("Fetching future appointments for client", {
tenantId: this.tenantId,
tunnelId,
});
const db = await this.getDb();
// Get all future appointments for this tunnel
const now = new Date();
const appointments = await db
.select({
id: tenantSchema.appointment.id,
appointmentDate: tenantSchema.appointment.appointmentDate,
status: tenantSchema.appointment.status,
channelId: tenantSchema.appointment.channelId,
encryptedPayload: tenantSchema.appointment.encryptedPayload,
iv: tenantSchema.appointment.iv,
authTag: tenantSchema.appointment.authTag,
})
.from(tenantSchema.appointment)
.where(
and(
eq(tenantSchema.appointment.tunnelId, tunnelId),
gte(tenantSchema.appointment.appointmentDate, now),
),
)
.orderBy(asc(tenantSchema.appointment.appointmentDate));
log.debug("Future appointments retrieved", {
tenantId: this.tenantId,
tunnelId,
count: appointments.length,
});
return appointments.map((apt) => ({
id: apt.id,
appointmentDate: apt.appointmentDate.toISOString(),
status: apt.status,
channelId: apt.channelId,
encryptedPayload: apt.encryptedPayload || "",
iv: apt.iv || "",
authTag: apt.authTag || "",
}));
}
/**
* Delete appointment by client with challenge-response authentication
* This verifies that the client knows their PIN before allowing deletion
*/
public async deleteAppointmentByClient(
appointmentId: string,
emailHash: string,
challengeId: string,
challengeResponse: string,
): Promise<void> {
const log = logger.setContext("AppointmentService");
log.info("Client deleting appointment with authentication", {
tenantId: this.tenantId,
appointmentId,
emailHashPrefix: emailHash.slice(0, 8),
});
// 1. Verify challenge-response
const storedChallenge = await challengeStore.consume(challengeId, this.tenantId);
if (!storedChallenge) {
log.warn("Challenge not found or expired", {
tenantId: this.tenantId,
challengeId,
});
throw new NotFoundError("Challenge not found or expired");
}
// Verify that the challenge belongs to this email
if (storedChallenge.emailHash !== emailHash) {
log.warn("Challenge does not belong to this client", {
tenantId: this.tenantId,
challengeId,
emailHashPrefix: emailHash.slice(0, 8),
});
throw new ValidationError("Invalid authentication");
}
// Validate challenge response using constant-time comparison
const expectedChallenge = storedChallenge.challenge;
const challengeBuffer = Buffer.from(challengeResponse, "base64");
const expectedBuffer = Buffer.from(expectedChallenge, "base64");
if (
challengeBuffer.length !== expectedBuffer.length ||
!timingSafeEqual(challengeBuffer, expectedBuffer)
) {
log.warn("Challenge response mismatch", {
tenantId: this.tenantId,
challengeId,
emailHashPrefix: emailHash.slice(0, 8),
});
// Record failed attempt for throttling
await challengeThrottleService.recordFailedAttempt(emailHash, "pin");
throw new ValidationError("Invalid challenge response");
}
// Clear throttle on successful verification
await challengeThrottleService.clearThrottle(emailHash, "pin");
const db = await this.getDb();
// 2. Get appointment and verify it belongs to this client's tunnel
const appointmentResult = await db
.select({
id: tenantSchema.appointment.id,
tunnelId: tenantSchema.appointment.tunnelId,
appointmentDate: tenantSchema.appointment.appointmentDate,
channelId: tenantSchema.appointment.channelId,
})
.from(tenantSchema.appointment)
.where(eq(tenantSchema.appointment.id, appointmentId))
.limit(1);
if (appointmentResult.length === 0) {
log.warn("Appointment not found", { appointmentId, tenantId: this.tenantId });
throw new NotFoundError("Appointment not found");
}
const appointment = appointmentResult[0];
// 3. Verify the appointment belongs to this client's tunnel
const tunnelResult = await db
.select({
id: tenantSchema.clientAppointmentTunnel.id,
emailHash: tenantSchema.clientAppointmentTunnel.emailHash,
})
.from(tenantSchema.clientAppointmentTunnel)
.where(eq(tenantSchema.clientAppointmentTunnel.id, appointment.tunnelId))
.limit(1);
if (tunnelResult.length === 0) {
log.warn("Client tunnel not found", {
tunnelId: appointment.tunnelId,
tenantId: this.tenantId,
});
throw new NotFoundError("Client not found");
}
const tunnel = tunnelResult[0];
if (tunnel.emailHash !== emailHash) {
log.warn("Appointment does not belong to this client", {
appointmentId,
tenantId: this.tenantId,
emailHashPrefix: emailHash.slice(0, 8),
});
throw new ValidationError("Appointment does not belong to this client");
}
// 4. Delete the appointment
await db.delete(tenantSchema.appointment).where(eq(tenantSchema.appointment.id, appointmentId));
log.info("Appointment deleted successfully by client", {
tenantId: this.tenantId,
appointmentId,
emailHashPrefix: emailHash.slice(0, 8),
});
const notificationService = await NotificationService.forTenant(this.tenantId);
await notificationService.createNotification({
channelId: appointment.channelId,
type: "APPOINTMENT_CANCELLED",
metaData: {
appointmentId,
},
});
log.info("Staff notification sent for client-initiated deletion", {
tenantId: this.tenantId,
appointmentId,
channelId: appointment.channelId,
});
}
/**
* Get the tenant's database connection (cached)
*/
+4 -3
View File
@@ -9,6 +9,7 @@ import { goto } from "$app/navigation";
import { ROUTES } from "$lib/const/routes";
import { agents } from "./agents";
import { channels } from "./channels";
import { resolve } from "$app/paths";
const log = logger.setContext("TenantsStore");
@@ -54,7 +55,7 @@ const createTenantsStore = () => {
// Redirect to dashboard main if tenant changed to avaoid showing data from previous tenant
if (tenantId !== curTenant) {
goto(ROUTES.DASHBOARD.MAIN);
goto(resolve(ROUTES.DASHBOARD.MAIN));
}
},
reload: async () => {
@@ -81,7 +82,7 @@ const createTenantsStore = () => {
description: m["dashboard.onboarding.notification.ongoing.description"](),
action: {
label: m["dashboard.onboarding.notification.ongoing.action"](),
onClick: () => goto(ROUTES.DASHBOARD.MAIN),
onClick: () => goto(resolve(ROUTES.DASHBOARD.MAIN)),
},
});
} else {
@@ -92,7 +93,7 @@ const createTenantsStore = () => {
description: m["dashboard.onboarding.notification.done.description"](),
action: {
label: m["dashboard.onboarding.notification.done.action"](),
onClick: () => goto(ROUTES.MAIN),
onClick: () => goto(resolve(ROUTES.MAIN)),
},
});
}
+2
View File
@@ -39,7 +39,9 @@ export type TPublicAppointment = {
duration: number;
};
data?: {
salutation: string;
name: string;
shareEmail: boolean;
email: string;
phone?: string;
};
+3 -2
View File
@@ -1,4 +1,5 @@
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { ROUTES } from "$lib/const/routes";
import { auth } from "$lib/stores/auth";
@@ -19,7 +20,7 @@ export const refreshSession = async () => {
if (!response.ok) {
if (response.status === 401) {
auth.setRefreshing(false);
goto(ROUTES.LOGOUT);
goto(resolve(ROUTES.LOGOUT));
return;
}
}
@@ -42,7 +43,7 @@ export const refreshUserData = async () => {
if (!response.ok) {
if (response.status === 401) {
goto(ROUTES.LOGOUT);
goto(resolve(ROUTES.LOGOUT));
return;
}
}
@@ -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);
@@ -18,7 +19,9 @@
const form = superForm(
{
salutation: "",
name: "",
shareEmail: false,
email: "",
phone: get(publicStore).tenant?.requirePhone ? "" : undefined,
},
@@ -35,7 +38,9 @@
proceed({
...appointment,
data: {
salutation: validation.data.salutation,
name: validation.data.name,
shareEmail: validation.data.shareEmail,
email: validation.data.email,
phone: validation.data.phone,
},
@@ -65,6 +70,15 @@
>
</Alert.Root>
<Form.Root {enhance} class="flex flex-col gap-4">
<Form.Field {form} name="salutation" class="hidden">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.salutation"]()}</Form.Label>
<Input {...props} bind:value={$formData.salutation} type="text" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="name">
<Form.Control>
{#snippet children({ props })}
@@ -74,18 +88,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 })}
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { m } from "$i18n/messages";
import { getLocale } from "$i18n/runtime";
import { Button } from "$lib/components/ui/button";
@@ -39,7 +40,7 @@
)
.then(() => {
const isRequest = channel.requiresConfirmation;
goto(ROUTES.APPOINTMENT_BOOKED, { state: { isRequest } });
goto(resolve(ROUTES.APPOINTMENT_BOOKED), { state: { isRequest } });
})
.catch(() => {
toast.error(m["public.steps.summary.error"]());
@@ -1,5 +1,6 @@
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { ROUTES } from "$lib/const/routes";
import { publicStore } from "$lib/stores/public";
import type { TPublicAppointment, TPublicSchedule } from "$lib/types/public";
@@ -74,7 +75,7 @@ export const proceed = (
newAppointment: updatedAppointment,
}));
}
goto(`${ROUTES.BOOK_APPOINTMENT}/${newAppointment.channel}`);
goto(resolve(`${ROUTES.BOOK_APPOINTMENT}/${newAppointment.channel}`));
return { ...newAppointment, step: "SELECT_AGENT" };
}
default:
@@ -83,7 +84,7 @@ export const proceed = (
};
export const resest = () => {
goto(ROUTES.BOOK_APPOINTMENT, { invalidateAll: true });
goto(resolve(ROUTES.BOOK_APPOINTMENT), { invalidateAll: true });
};
export const fetchSchedule = async (opts: {
@@ -9,6 +9,7 @@
import Check from "@lucide/svelte/icons/check";
import { ROUTES } from "$lib/const/routes";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
const { data } = $props();
</script>
@@ -65,7 +66,7 @@
size="lg"
class="w-full"
onclick={() =>
goto(ROUTES.SETUP_PASSKEY, {
goto(resolve(ROUTES.SETUP_PASSKEY), {
state: {
id: confirmation.id,
email: confirmation.email,
@@ -15,6 +15,7 @@
}: { formId: string; onEvent: EventReporter; data: { form: SuperValidated<Infer<FormSchema>> } } =
$props();
// svelte-ignore state_referenced_locally
const form = superForm(data.form, {
validators: zodClient(formSchema),
onResult: async (event) => {
@@ -23,6 +23,7 @@
import { formSchema, type FormSchema } from "./schema";
import { onMount } from "svelte";
import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto";
import { resolve } from "$app/paths";
let {
data,
@@ -37,6 +38,7 @@
let kyberKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array } | undefined = $state();
let registrationChallenge: string | undefined = $state();
// svelte-ignore state_referenced_locally
const form = superForm(data.form, {
validators: zodClient(formSchema),
onChange: (event) => {
@@ -48,7 +50,7 @@
if (event.result.type === "success") {
toast.success(m["setupPasskey.success"]());
await storeStaffKeyPair();
await goto(ROUTES.LOGIN);
await goto(resolve(ROUTES.LOGIN));
} else {
toast.error(m["setupPasskey.error"]());
}
+6 -5
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { m } from "$i18n/messages";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import { Button } from "$lib/components/ui/button";
@@ -72,7 +73,7 @@
{
label: m["dashboard.tenants.setup.sections.tenants.action"](),
onClick: () => {
goto(ROUTES.DASHBOARD.TENANTS, { state: { action: "add" } });
goto(resolve(ROUTES.DASHBOARD.TENANTS), { state: { action: "add" } });
},
},
],
@@ -103,7 +104,7 @@
{
label: m["dashboard.onboarding.sections.settings.action"](),
onClick: () => {
goto(ROUTES.DASHBOARD.SETTINGS);
goto(resolve(ROUTES.DASHBOARD.SETTINGS));
},
},
],
@@ -116,7 +117,7 @@
{
label: m["dashboard.onboarding.sections.agents.action"](),
onClick: () => {
goto(ROUTES.DASHBOARD.AGENTS, { state: { action: "add" } });
goto(resolve(ROUTES.DASHBOARD.AGENTS), { state: { action: "add" } });
},
},
],
@@ -130,7 +131,7 @@
{
label: m["dashboard.onboarding.sections.channels.action"](),
onClick: () => {
goto(ROUTES.DASHBOARD.CHANNELS, { state: { action: "add" } });
goto(resolve(ROUTES.DASHBOARD.CHANNELS), { state: { action: "add" } });
},
},
],
@@ -144,7 +145,7 @@
{
label: m["dashboard.onboarding.sections.staff.action"](),
onClick: () => {
goto(ROUTES.DASHBOARD.STAFF, { state: { action: "add" } });
goto(resolve(ROUTES.DASHBOARD.STAFF), { state: { action: "add" } });
},
},
],
@@ -14,6 +14,7 @@
const agents = $derived($agentsStore.agents ?? []);
let { entity, done }: { entity: TAbsence; done: () => void } = $props();
// svelte-ignore state_referenced_locally
const form = superForm(
{ id: entity.id, agent: entity.agentId },
{
@@ -18,6 +18,8 @@
let { entity, done }: { entity: TAbsence; done: () => void } = $props();
const agents = $derived($agentsStore.agents ?? []);
// svelte-ignore state_referenced_locally
const form = superForm(
{
id: entity.id,
@@ -43,8 +45,10 @@
);
let isSubmitting = $state(false);
let startDate = toInputDateTime(entity.startDate);
let endDate = toInputDateTime(entity.endDate);
// svelte-ignore state_referenced_locally
let startDate = $state(toInputDateTime(entity.startDate));
// svelte-ignore state_referenced_locally
let endDate = $state(toInputDateTime(entity.endDate));
let isAllDay = $state(
startDate.hour === 0 && startDate.minute === 0 && endDate.hour === 0 && endDate.minute === 0,
);
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { m } from "$i18n/messages";
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
@@ -22,7 +23,7 @@
<Item.Group class="gap-2">
<Item.Root variant="outline">
{#snippet child({ props })}
<a href={ROUTES.DASHBOARD.ACCOUNT.GENERAL} {...props}>
<a href={resolve(ROUTES.DASHBOARD.ACCOUNT.GENERAL)} {...props}>
<Item.Content>
<Item.Title>
<Text style="md">{m["account.general.title"]()}</Text>
@@ -39,7 +40,7 @@
</Item.Root>
<Item.Root variant="outline">
{#snippet child({ props })}
<a href={ROUTES.DASHBOARD.ACCOUNT.CHANGE_EMAIL} {...props}>
<a href={resolve(ROUTES.DASHBOARD.ACCOUNT.CHANGE_EMAIL)} {...props}>
<Item.Content>
<Item.Title>
<Text style="md">{m["account.change-email.title"]()}</Text>
@@ -56,7 +57,7 @@
</Item.Root>
<Item.Root variant="outline">
{#snippet child({ props })}
<a href={ROUTES.DASHBOARD.ACCOUNT.PASSKEYS} {...props}>
<a href={resolve(ROUTES.DASHBOARD.ACCOUNT.PASSKEYS)} {...props}>
<Item.Content>
<Item.Title>
<Text style="md">{m["account.passkeys.title"]()}</Text>
@@ -74,7 +75,7 @@
{#if $auth.user?.role === "GLOBAL_ADMIN"}
<Item.Root variant="outline">
{#snippet child({ props })}
<a href={ROUTES.DASHBOARD.ACCOUNT.CHANGE_PASSPHRASE} {...props}>
<a href={resolve(ROUTES.DASHBOARD.ACCOUNT.CHANGE_PASSPHRASE)} {...props}>
<Item.Content>
<Item.Title>
<Text style="md">{m["account.change-passphrase.title"]()}</Text>
@@ -14,6 +14,7 @@
let { entity, done }: { entity: TAgent; done: () => void } = $props();
// svelte-ignore state_referenced_locally
const form = superForm(
{ id: entity.id, name: "" },
{
@@ -17,8 +17,10 @@
let { entity, done }: { entity: TAgent; done: () => void } = $props();
const tenantLocales = get(tenants).currentTenant?.languages ?? [];
// svelte-ignore state_referenced_locally
const form = superForm(
{
$state.snapshot({
id: entity.id,
name: entity.name,
descriptions: tenantLocales.reduce(
@@ -26,7 +28,7 @@
{} as { [key: string]: string },
),
image: entity.image ?? "",
},
}),
{
dataType: "json",
validators: zodClient(formSchema),
@@ -1,5 +1,6 @@
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { ROUTES } from "$lib/const/routes";
import type { TCalendar, TCalendarItem } from "$lib/types/calendar";
import { toCalendarDateTime, toZoned, type CalendarDate } from "@internationalized/date";
@@ -32,7 +33,7 @@ export const fetchCalendar = async (opts: { tenant: string; startDate: CalendarD
}
} else {
if (res.status === 401) {
goto(ROUTES.LOGIN);
goto(resolve(ROUTES.LOGIN));
} else {
console.error("Unable to fetch calendar", res.status, res.statusText);
}
@@ -15,6 +15,7 @@
let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props();
// svelte-ignore state_referenced_locally
const form = superForm(
{ id: entity.id, name: "" },
{
@@ -23,6 +23,8 @@
const agents = $derived($agentsStore.agents ?? []);
const tenantLocales = get(tenants).currentTenant?.languages ?? [];
// svelte-ignore state_referenced_locally
const form = superForm(
{
id: entity.id,
@@ -14,6 +14,7 @@
let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props();
// svelte-ignore state_referenced_locally
const form = superForm(
{ id: entity.id, pause: !entity.pause },
{
@@ -30,6 +30,8 @@
key: it,
label: translatedLocales[it as keyof typeof translatedLocales],
}));
// svelte-ignore state_referenced_locally
const form = superForm(
{
id: entity.id,
@@ -14,6 +14,7 @@
let { entity, done }: { entity: TStaff; done: () => void } = $props();
// svelte-ignore state_referenced_locally
const form = superForm(
{ id: entity.id, email: "", confirmationState: entity.confirmationState },
{
@@ -14,6 +14,8 @@
let { entity, done }: { entity: TStaff; done: () => void } = $props();
const availableRoles = $derived(roles.filter((it) => it.value !== "GLOBAL_ADMIN"));
// svelte-ignore state_referenced_locally
const form = superForm(
{
id: entity.id,
@@ -12,7 +12,8 @@
let { data, done }: { done: () => void; data: { form: SuperValidated<Infer<FormSchema>> } } =
$props();
const form = superForm(data.form, {
// svelte-ignore state_referenced_locally
const form = superForm($state.snapshot(data.form), {
validators: zodClient(formSchema),
onResult: async (event) => {
if (event.result.type === "success") {
@@ -17,6 +17,7 @@
let { entity, done }: { entity: TTenant; done: () => void } = $props();
// svelte-ignore state_referenced_locally
const form = superForm(
{ id: entity.id, shortname: "" },
{
@@ -10,6 +10,7 @@
let { entity, done }: { entity: TTenant; done: () => void } = $props();
// svelte-ignore state_referenced_locally
const form = superForm(
{ id: entity.id, shortName: entity.shortName },
{
+10 -9
View File
@@ -5,20 +5,21 @@
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 { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { baseSchema, 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 { resolve } from "$app/paths";
let { formId, onEvent }: { formId: string; onEvent: EventReporter } = $props();
@@ -42,7 +43,7 @@
onResult: async (event) => {
if (event.result.type === "success") {
auth.setUser(event.result.data?.user);
await goto(ROUTES.DASHBOARD.MAIN);
await goto(resolve(ROUTES.DASHBOARD.MAIN));
} else {
toast.error(m["login.error"]());
}
@@ -9,6 +9,7 @@ export const load = async (event) => {
// Check if global admin exists
const adminExists = await UserService.adminExists();
// @ts-expect-error any string should be tested against blocklist
if (blocklist.includes(cleanedId) && adminExists) {
redirect(302, ROUTES.LOGIN);
}
@@ -14,13 +14,16 @@
import { goto } from "$app/navigation";
import { ROUTES } from "$lib/const/routes";
import { Input } from "$lib/components/ui/input";
import { resolve } from "$app/paths";
let { data }: { data: { form: SuperValidated<Infer<FormSchema>> } } = $props();
const formId = "resend-confirmation-email";
let email = $state("");
let isSubmitting = $state(false);
const form = superForm(data.form, {
// svelte-ignore state_referenced_locally
const form = superForm($state.snapshot(data.form), {
resetForm: false,
validators: zodClient(formSchema),
onResult: (event) => {
@@ -41,7 +44,7 @@
// This page is only of help, if you have just created an admin account
if (!email) {
await goto(ROUTES.SETUP.CREATE_ADMIN_ACCOUNT);
await goto(resolve(ROUTES.SETUP.CREATE_ADMIN_ACCOUNT));
}
// Set email from previous step
@@ -20,6 +20,7 @@
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
import { arrayBufferToBase64, fetchChallenge, generatePasskey } from "$lib/utils/passkey";
import logger from "$lib/logger";
import { resolve } from "$app/paths";
let {
data,
@@ -28,7 +29,8 @@
}: { formId: string; onEvent: EventReporter; data: { form: SuperValidated<Infer<FormSchema>> } } =
$props();
const form = superForm(data.form, {
// svelte-ignore state_referenced_locally
const form = superForm($state.snapshot(data.form), {
validators: zodClient(formSchema),
onChange: (event) => {
if (event.paths.includes("email")) {
@@ -38,7 +40,7 @@
onResult: async (event) => {
if (event.result.type === "success") {
toast.success(m["setup.create_admin_account.success"]());
await goto(ROUTES.SETUP.CHECK_EMAIL, {
await goto(resolve(ROUTES.SETUP.CHECK_EMAIL), {
state: { email: event.result.data?.form.data.email },
});
}
+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>
@@ -146,6 +146,8 @@ describe("POST /api/admin/tenant", () => {
isSubRequest: false,
fetch: fetch,
setHeaders: vi.fn(),
tracing: { enabled: false, root: "", current: "" },
isRemoteRequest: false,
});
beforeEach(() => {
@@ -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"),
});
@@ -0,0 +1,173 @@
/**
* API Route: Client Delete Own Appointment
*
* Allows a client to delete their own appointment after successful PIN verification.
* Requires challenge-response authentication to prove ownership.
*/
import type { RequestHandler } from "@sveltejs/kit";
import { json } from "@sveltejs/kit";
import { z } from "zod";
import { logger } from "$lib/logger";
import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors";
import { AppointmentService } from "$lib/server/services/appointment-service";
import { registerOpenAPIRoute } from "$lib/server/openapi";
const requestSchema = z.object({
emailHash: z.string().min(1),
challengeId: z.string().min(1),
challengeResponse: z.string().min(1),
});
// Register OpenAPI documentation for DELETE
registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}/delete-by-client", "DELETE", {
summary: "Delete appointment by client",
description:
"Allows a client to delete their own appointment. Requires challenge-response authentication to prove the client knows their PIN. The appointment must belong to the client's tunnel.",
tags: ["Appointments", "Clients"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "appointmentId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Appointment ID to delete",
},
],
requestBody: {
description: "Challenge-response authentication data",
content: {
"application/json": {
schema: {
type: "object",
properties: {
emailHash: {
type: "string",
description: "SHA-256 hash of client email",
},
challengeId: {
type: "string",
description: "Challenge ID from /appointments/challenge endpoint",
},
challengeResponse: {
type: "string",
description: "Decrypted challenge response (base64 encoded)",
},
},
required: ["emailHash", "challengeId", "challengeResponse"],
},
},
},
},
responses: {
"200": {
description: "Appointment deleted successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: {
type: "string",
example: "Appointment deleted successfully",
},
},
},
},
},
},
"400": {
description: "Invalid authentication or appointment doesn't belong to client",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Appointment or client not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
/**
* DELETE /api/tenants/[id]/appointments/[appointmentId]/delete-by-client
*
* Deletes an appointment after verifying the client's identity via challenge-response.
* This ensures only the client who owns the appointment (and knows the PIN) can delete it.
*/
export const DELETE: RequestHandler = async ({ request, params }) => {
const log = logger.setContext("API.ClientDeleteAppointment");
const tenantId = params.id;
const appointmentId = params.appointmentId;
try {
if (!tenantId) {
throw new ValidationError("Tenant ID is required");
}
if (!appointmentId) {
throw new ValidationError("Appointment ID is required");
}
const body = await request.json();
const { emailHash, challengeId, challengeResponse } = requestSchema.parse(body);
log.info("Client deleting appointment", {
tenantId,
appointmentId,
emailHashPrefix: emailHash.slice(0, 8),
});
// Delete appointment with authentication verification
const appointmentService = await AppointmentService.forTenant(tenantId);
await appointmentService.deleteAppointmentByClient(
appointmentId,
emailHash,
challengeId,
challengeResponse,
);
log.info("Appointment deleted successfully by client", {
tenantId,
appointmentId,
emailHashPrefix: emailHash.slice(0, 8),
});
return json({
message: "Appointment deleted successfully",
});
} catch (error) {
logError(log)("Error deleting appointment by client", error);
if (error instanceof BackendError) {
return error.toJson();
}
if (error instanceof z.ZodError) {
return new ValidationError("Invalid request data").toJson();
}
return new InternalError().toJson();
}
};
@@ -0,0 +1,216 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { DELETE } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
vi.mock("$lib/logger", async (importOriginal) => {
const actual = await importOriginal<typeof import("$lib/logger")>();
return {
...actual,
logger: {
setContext: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
},
};
});
vi.mock("$lib/server/services/appointment-service");
const mockTenantId = "tenant-123";
const mockAppointmentId = "appointment-456";
const mockEmailHash = "email-hash-abc123";
const mockChallengeId = "challenge-789";
const mockChallengeResponse = "decrypted-challenge";
describe("DELETE /api/tenants/[id]/appointments/[appointmentId]/delete-by-client", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const createMockRequest = (body?: any): RequestEvent => {
return {
request: {
json: vi.fn().mockResolvedValue(
body || {
emailHash: mockEmailHash,
challengeId: mockChallengeId,
challengeResponse: mockChallengeResponse,
},
),
} as any,
params: { id: mockTenantId, appointmentId: mockAppointmentId },
} as any;
};
it("should delete appointment successfully with valid authentication", async () => {
const { AppointmentService } = await import("$lib/server/services/appointment-service");
const mockService = {
deleteAppointmentByClient: vi.fn().mockResolvedValue(undefined),
};
vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any);
const event = createMockRequest();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Appointment deleted successfully");
expect(mockService.deleteAppointmentByClient).toHaveBeenCalledWith(
mockAppointmentId,
mockEmailHash,
mockChallengeId,
mockChallengeResponse,
);
});
it("should return 422 when emailHash is missing", async () => {
const event = createMockRequest({
challengeId: mockChallengeId,
challengeResponse: mockChallengeResponse,
});
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBeDefined();
});
it("should return 422 when challengeId is missing", async () => {
const event = createMockRequest({
emailHash: mockEmailHash,
challengeResponse: mockChallengeResponse,
});
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBeDefined();
});
it("should return 422 when challengeResponse is missing", async () => {
const event = createMockRequest({
emailHash: mockEmailHash,
challengeId: mockChallengeId,
});
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBeDefined();
});
it("should return 422 when tenant ID is missing", async () => {
const event = {
request: {
json: vi.fn().mockResolvedValue({
emailHash: mockEmailHash,
challengeId: mockChallengeId,
challengeResponse: mockChallengeResponse,
}),
} as any,
params: { id: undefined, appointmentId: mockAppointmentId },
} as any;
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBeDefined();
});
it("should return 422 when appointment ID is missing", async () => {
const event = {
request: {
json: vi.fn().mockResolvedValue({
emailHash: mockEmailHash,
challengeId: mockChallengeId,
challengeResponse: mockChallengeResponse,
}),
} as any,
params: { id: mockTenantId, appointmentId: undefined },
} as any;
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBeDefined();
});
it("should return 422 when emailHash is empty", async () => {
const event = createMockRequest({
emailHash: "",
challengeId: mockChallengeId,
challengeResponse: mockChallengeResponse,
});
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBeDefined();
});
it("should handle NotFoundError when challenge is invalid", async () => {
const { AppointmentService } = await import("$lib/server/services/appointment-service");
const { NotFoundError } = await import("$lib/server/utils/errors");
const mockService = {
deleteAppointmentByClient: vi
.fn()
.mockRejectedValue(new NotFoundError("Challenge not found")),
};
vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any);
const event = createMockRequest();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Challenge not found");
});
it("should handle ValidationError when appointment doesn't belong to client", async () => {
const { AppointmentService } = await import("$lib/server/services/appointment-service");
const { ValidationError } = await import("$lib/server/utils/errors");
const mockService = {
deleteAppointmentByClient: vi
.fn()
.mockRejectedValue(new ValidationError("Appointment does not belong to this client")),
};
vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any);
const event = createMockRequest();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Appointment does not belong to this client");
});
it("should handle service errors gracefully", async () => {
const { AppointmentService } = await import("$lib/server/services/appointment-service");
const mockService = {
deleteAppointmentByClient: vi.fn().mockRejectedValue(new Error("Database error")),
};
vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any);
const event = createMockRequest();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBeDefined();
});
});
@@ -0,0 +1,171 @@
import { json } from "@sveltejs/kit";
import { z } from "zod";
import { AppointmentService } from "$lib/server/services/appointment-service";
import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
const requestSchema = z.object({
clientEmail: z.string().email(),
clientLanguage: z.string().optional().default("de"),
});
// Register OpenAPI documentation for DELETE
registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "DELETE", {
summary: "Delete appointment",
description:
"Deletes an appointment, removing it from the database, sending a cancellation email to the client, and creating notifications for channel staff. Accessible to staff and tenant admins.",
tags: ["Appointments"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "appointmentId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Appointment ID",
},
],
requestBody: {
content: {
"application/json": {
schema: {
type: "object",
properties: {
clientEmail: {
type: "string",
format: "email",
description: "Email address of the client",
example: "client@example.com",
},
clientLanguage: {
type: "string",
description: "Client's preferred language for the cancellation email",
default: "de",
example: "de",
},
},
required: ["clientEmail"],
},
},
},
},
responses: {
"200": {
description: "Appointment deleted successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" },
},
required: ["message"],
},
},
},
},
"400": {
description: "Invalid input data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Insufficient permissions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Appointment not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
export const DELETE: RequestHandler = async ({ params, request, locals }) => {
const log = logger.setContext("API");
try {
const tenantId = params.id;
const appointmentId = params.appointmentId;
// Check if user is authenticated
if (!tenantId || !appointmentId) {
throw new ValidationError("Tenant ID and appointment ID are required");
}
checkPermission(locals, tenantId);
// Parse request body
const body = await request.json();
const validationResult = requestSchema.safeParse(body);
if (!validationResult.success) {
throw new ValidationError("Invalid request body");
}
const { clientEmail, clientLanguage } = validationResult.data;
log.debug("Deleting appointment", {
tenantId,
appointmentId,
clientEmail,
requestedBy: locals.user?.id,
});
const appointmentService = await AppointmentService.forTenant(tenantId);
await appointmentService.deleteAppointmentByStaff(appointmentId, clientEmail, clientLanguage);
log.debug("Appointment deleted successfully", {
tenantId,
appointmentId,
requestedBy: locals.user?.id,
});
return json({
message: "Appointment deleted successfully",
});
} catch (error) {
logError(log)("Error deleting appointment", error, locals.user?.id, params.id);
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
};
@@ -0,0 +1,245 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { DELETE } from "../+server";
import * as appointmentService from "$lib/server/services/appointment-service";
import { NotFoundError } from "$lib/server/utils/errors";
// Mock the appointment service
vi.mock("$lib/server/services/appointment-service", () => ({
AppointmentService: {
forTenant: vi.fn(),
},
}));
// Mock permission check
vi.mock("$lib/server/utils/permissions", () => ({
checkPermission: vi.fn(),
}));
// Mock logger
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
describe("DELETE /api/tenants/[id]/appointments/[appointmentId]/delete", () => {
const mockTenantId = "tenant-123";
const mockAppointmentId = "appointment-456";
const mockClientEmail = "client@example.com";
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should delete appointment successfully with valid data", async () => {
const mockDeleteAppointmentByStaff = vi.fn().mockResolvedValue(undefined);
vi.mocked(appointmentService.AppointmentService.forTenant).mockResolvedValue({
deleteAppointmentByStaff: mockDeleteAppointmentByStaff,
} as any);
const request = new Request("http://localhost", {
method: "DELETE",
body: JSON.stringify({
clientEmail: mockClientEmail,
clientLanguage: "de",
}),
headers: {
"Content-Type": "application/json",
},
});
const response = await DELETE({
params: { id: mockTenantId, appointmentId: mockAppointmentId },
request,
locals: { user: { id: "user-123" } },
} as any);
const result = await response.json();
expect(response.status).toBe(200);
expect(result).toEqual({
message: "Appointment deleted successfully",
});
expect(mockDeleteAppointmentByStaff).toHaveBeenCalledWith(
mockAppointmentId,
mockClientEmail,
"de",
);
});
it("should use default language when not provided", async () => {
const mockDeleteAppointmentByStaff = vi.fn().mockResolvedValue(undefined);
vi.mocked(appointmentService.AppointmentService.forTenant).mockResolvedValue({
deleteAppointmentByStaff: mockDeleteAppointmentByStaff,
} as any);
const request = new Request("http://localhost", {
method: "DELETE",
body: JSON.stringify({
clientEmail: mockClientEmail,
}),
headers: {
"Content-Type": "application/json",
},
});
const response = await DELETE({
params: { id: mockTenantId, appointmentId: mockAppointmentId },
request,
locals: { user: { id: "user-123" } },
} as any);
expect(response.status).toBe(200);
expect(mockDeleteAppointmentByStaff).toHaveBeenCalledWith(
mockAppointmentId,
mockClientEmail,
"de",
);
});
it("should return 422 when clientEmail is missing", async () => {
const request = new Request("http://localhost", {
method: "DELETE",
body: JSON.stringify({}),
headers: {
"Content-Type": "application/json",
},
});
const response = await DELETE({
params: { id: mockTenantId, appointmentId: mockAppointmentId },
request,
locals: { user: { id: "user-123" } },
} as any);
expect(response.status).toBe(422);
});
it("should return 422 when clientEmail is invalid", async () => {
const request = new Request("http://localhost", {
method: "DELETE",
body: JSON.stringify({
clientEmail: "invalid-email",
}),
headers: {
"Content-Type": "application/json",
},
});
const response = await DELETE({
params: { id: mockTenantId, appointmentId: mockAppointmentId },
request,
locals: { user: { id: "user-123" } },
} as any);
expect(response.status).toBe(422);
});
it("should return 422 when tenantId is missing", async () => {
const request = new Request("http://localhost", {
method: "DELETE",
body: JSON.stringify({
clientEmail: mockClientEmail,
}),
headers: {
"Content-Type": "application/json",
},
});
const response = await DELETE({
params: { appointmentId: mockAppointmentId },
request,
locals: { user: { id: "user-123" } },
} as any);
expect(response.status).toBe(422);
});
it("should return 422 when appointmentId is missing", async () => {
const request = new Request("http://localhost", {
method: "DELETE",
body: JSON.stringify({
clientEmail: mockClientEmail,
}),
headers: {
"Content-Type": "application/json",
},
});
const response = await DELETE({
params: { id: mockTenantId },
request,
locals: { user: { id: "user-123" } },
} as any);
expect(response.status).toBe(422);
});
it("should return 404 when appointment is not found", async () => {
const mockDeleteAppointmentByStaff = vi
.fn()
.mockRejectedValue(new NotFoundError("Appointment not found"));
vi.mocked(appointmentService.AppointmentService.forTenant).mockResolvedValue({
deleteAppointmentByStaff: mockDeleteAppointmentByStaff,
} as any);
const request = new Request("http://localhost", {
method: "DELETE",
body: JSON.stringify({
clientEmail: mockClientEmail,
}),
headers: {
"Content-Type": "application/json",
},
});
const response = await DELETE({
params: { id: mockTenantId, appointmentId: mockAppointmentId },
request,
locals: { user: { id: "user-123" } },
} as any);
expect(response.status).toBe(404);
});
it("should handle service errors gracefully", async () => {
const mockDeleteAppointmentByStaff = vi
.fn()
.mockRejectedValue(new Error("Database connection failed"));
vi.mocked(appointmentService.AppointmentService.forTenant).mockResolvedValue({
deleteAppointmentByStaff: mockDeleteAppointmentByStaff,
} as any);
const request = new Request("http://localhost", {
method: "DELETE",
body: JSON.stringify({
clientEmail: mockClientEmail,
}),
headers: {
"Content-Type": "application/json",
},
});
const response = await DELETE({
params: { id: mockTenantId, appointmentId: mockAppointmentId },
request,
locals: { user: { id: "user-123" } },
} as any);
expect(response.status).toBe(500);
});
});
@@ -19,6 +19,7 @@ import {
getChannelTitle,
} from "$lib/server/email/email-service";
import { TenantAdminService } from "$lib/server/services/tenant-admin-service";
import { NotificationService } from "$lib/server/services/notification-service";
const requestSchema = z.object({
emailHash: z.string(),
@@ -27,8 +28,9 @@ 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"),
salutation: z.string().optional(),
encryptedAppointment: z.object({
encryptedPayload: z.string(),
iv: z.string(),
@@ -121,7 +123,6 @@ registerOpenAPIRoute("/tenants/{id}/appointments/add-to-tunnel", "POST", {
"tunnelId",
"channelId",
"appointmentDate",
"clientEmail",
"encryptedAppointment",
],
},
@@ -203,6 +204,10 @@ export const POST: RequestHandler = async ({ request, params }) => {
const body = await request.json();
const validatedData = requestSchema.parse(body);
if (validatedData.salutation) {
throw new ValidationError("Bees incoming");
}
logger.info("Adding appointment to existing tunnel", {
tenantId,
tunnelId: validatedData.tunnelId,
@@ -304,12 +309,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,
@@ -319,20 +318,37 @@ 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,
// send notification to tenant staff about new appointment request
const notificationService = NotificationService.forTenant(tenantId);
(await notificationService).createNotification({
type: "APPOINTMENT_REQUESTED",
channelId: validatedData.channelId,
metaData: { appointmentId: result.id },
});
}
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) {
logger.error("Failed to send appointment notification email", {
@@ -12,10 +12,11 @@ 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(),
salutation: z.string().optional(),
encryptedAppointment: z.object({
encryptedPayload: z.string(),
iv: z.string(),
@@ -74,7 +75,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 +139,6 @@ registerOpenAPIRoute("/tenants/{id}/appointments/create-new-client", "POST", {
"channelId",
"appointmentDate",
"emailHash",
"clientEmail",
"clientPublicKey",
"privateKeyShare",
"encryptedAppointment",
@@ -229,6 +229,10 @@ export const POST: RequestHandler = async ({ request, params }) => {
const body = await request.json();
const validatedData = requestSchema.parse(body);
if (validatedData.salutation) {
throw new ValidationError("Bees incoming");
}
logger.info("Creating new client appointment tunnel", {
tenantId,
tunnelId: validatedData.tunnelId,
@@ -0,0 +1,215 @@
/**
* API Route: Get Client's Future Appointments
*
* Returns all future appointments for a client after successful PIN verification.
* Client must be authenticated via challenge-response before accessing this endpoint.
*/
import type { RequestHandler } from "@sveltejs/kit";
import { json } from "@sveltejs/kit";
import { z } from "zod";
import { logger } from "$lib/logger";
import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors";
import { AppointmentService } from "$lib/server/services/appointment-service";
import { getTenantDb } from "$lib/server/db";
import { clientAppointmentTunnel } from "$lib/server/db/tenant-schema";
import { eq } from "drizzle-orm";
import { registerOpenAPIRoute } from "$lib/server/openapi";
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/appointments/my-appointments", "GET", {
summary: "Get client's future appointments",
description:
"Returns all future appointments for a client. Requires the client to be authenticated via PIN challenge-response flow. The emailHash header must match the authenticated client.",
tags: ["Appointments", "Clients"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "X-Email-Hash",
in: "header",
required: true,
schema: { type: "string" },
description: "SHA-256 hash of client email for authentication",
},
],
responses: {
"200": {
description: "Future appointments retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
appointments: {
type: "array",
items: {
type: "object",
properties: {
id: {
type: "string",
format: "uuid",
description: "Appointment ID",
},
appointmentDate: {
type: "string",
format: "date-time",
description: "Appointment date and time",
},
status: {
type: "string",
enum: ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"],
description: "Appointment status",
},
channelId: {
type: "string",
format: "uuid",
description: "Channel ID",
},
encryptedData: {
type: "object",
properties: {
encryptedPayload: {
type: "string",
description: "AES-encrypted appointment data",
},
iv: {
type: "string",
description: "Initialization vector for encryption",
},
authTag: {
type: "string",
description: "Authentication tag for AES-GCM",
},
},
required: ["encryptedPayload", "iv", "authTag"],
},
},
required: ["id", "appointmentDate", "status", "channelId", "encryptedData"],
},
},
},
required: ["appointments"],
},
},
},
},
"400": {
description: "Missing or invalid email hash header",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Client tunnel not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
const emailHashSchema = z.string().min(1);
/**
* GET /api/tenants/[id]/appointments/my-appointments
*
* Returns all future appointments for an authenticated client.
* The client must provide their email hash in the X-Email-Hash header.
*/
export const GET: RequestHandler = async ({ request, params }) => {
const log = logger.setContext("API.MyAppointments");
const tenantId = params.id;
try {
if (!tenantId) {
throw new ValidationError("Tenant ID is required");
}
// Get and validate email hash from header
const emailHash = request.headers.get("X-Email-Hash");
if (!emailHash) {
throw new ValidationError("X-Email-Hash header is required");
}
const validatedEmailHash = emailHashSchema.parse(emailHash);
log.debug("Fetching appointments for client", {
tenantId,
emailHashPrefix: validatedEmailHash.slice(0, 8),
});
// Get client tunnel to verify client exists and get tunnel ID
const db = await getTenantDb(tenantId);
const tunnelResult = await db
.select({
id: clientAppointmentTunnel.id,
})
.from(clientAppointmentTunnel)
.where(eq(clientAppointmentTunnel.emailHash, validatedEmailHash))
.limit(1);
if (tunnelResult.length === 0) {
log.warn("Client tunnel not found", {
tenantId,
emailHashPrefix: validatedEmailHash.slice(0, 8),
});
throw new ValidationError("Client not found");
}
const tunnel = tunnelResult[0];
// Get future appointments for this client
const appointmentService = await AppointmentService.forTenant(tenantId);
const appointments = await appointmentService.getFutureAppointmentsByTunnelId(tunnel.id);
log.debug("Future appointments retrieved successfully", {
tenantId,
tunnelId: tunnel.id,
appointmentCount: appointments.length,
});
return json({
appointments: appointments.map((apt) => ({
id: apt.id,
appointmentDate: apt.appointmentDate,
status: apt.status,
channelId: apt.channelId,
encryptedData: {
encryptedPayload: apt.encryptedPayload,
iv: apt.iv,
authTag: apt.authTag,
},
})),
});
} catch (error) {
logError(log)("Error fetching client appointments", error);
if (error instanceof BackendError) {
return error.toJson();
}
if (error instanceof z.ZodError) {
return new ValidationError("Invalid request data").toJson();
}
return new InternalError().toJson();
}
};
@@ -0,0 +1,237 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
vi.mock("$lib/logger", async (importOriginal) => {
const actual = await importOriginal<typeof import("$lib/logger")>();
return {
...actual,
logger: {
setContext: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
},
};
});
vi.mock("$lib/server/db", () => ({
getTenantDb: vi.fn(),
}));
vi.mock("$lib/server/services/appointment-service");
const mockTenantId = "tenant-123";
const mockEmailHash = "email-hash-abc123";
const mockTunnelId = "tunnel-456";
const mockFutureAppointments = [
{
id: "appointment-1",
appointmentDate: "2025-02-01T10:00:00.000Z",
status: "CONFIRMED",
channelId: "channel-1",
encryptedPayload: "encrypted-data-1",
iv: "iv-1",
authTag: "auth-tag-1",
},
{
id: "appointment-2",
appointmentDate: "2025-03-15T14:30:00.000Z",
status: "NEW",
channelId: "channel-2",
encryptedPayload: "encrypted-data-2",
iv: "iv-2",
authTag: "auth-tag-2",
},
];
describe("GET /api/tenants/[id]/appointments/my-appointments", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const createMockRequest = (emailHash?: string): RequestEvent => {
const headers = new Headers();
if (emailHash) {
headers.set("X-Email-Hash", emailHash);
}
return {
request: {
headers,
} as Request,
params: { id: mockTenantId },
} as any;
};
it("should return future appointments for authenticated client", async () => {
const { getTenantDb } = await import("$lib/server/db");
const { AppointmentService } = await import("$lib/server/services/appointment-service");
// Mock database query for tunnel
const mockDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: mockTunnelId }]),
}),
}),
}),
};
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
// Mock AppointmentService
const mockService = {
getFutureAppointmentsByTunnelId: vi.fn().mockResolvedValue(mockFutureAppointments),
};
vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any);
const event = createMockRequest(mockEmailHash);
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.appointments).toHaveLength(2);
expect(data.appointments[0]).toEqual({
id: "appointment-1",
appointmentDate: "2025-02-01T10:00:00.000Z",
status: "CONFIRMED",
channelId: "channel-1",
encryptedData: {
encryptedPayload: "encrypted-data-1",
iv: "iv-1",
authTag: "auth-tag-1",
},
});
expect(mockService.getFutureAppointmentsByTunnelId).toHaveBeenCalledWith(mockTunnelId);
});
it("should return empty array when client has no future appointments", async () => {
const { getTenantDb } = await import("$lib/server/db");
const { AppointmentService } = await import("$lib/server/services/appointment-service");
const mockDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: mockTunnelId }]),
}),
}),
}),
};
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
const mockService = {
getFutureAppointmentsByTunnelId: vi.fn().mockResolvedValue([]),
};
vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any);
const event = createMockRequest(mockEmailHash);
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.appointments).toHaveLength(0);
});
it("should return 422 when X-Email-Hash header is missing", async () => {
const event = createMockRequest();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBeDefined();
});
it("should return 422 when tenant ID is missing", async () => {
const event = {
request: {
headers: new Headers({ "X-Email-Hash": mockEmailHash }),
} as Request,
params: { id: undefined },
} as any;
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBeDefined();
});
it("should return 422 when client tunnel is not found", async () => {
const { getTenantDb } = await import("$lib/server/db");
const mockDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]), // No tunnel found
}),
}),
}),
};
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
const event = createMockRequest(mockEmailHash);
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Client not found");
});
it("should return 422 when email hash is empty", async () => {
const event = createMockRequest("");
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBeDefined();
});
it("should handle database errors gracefully", async () => {
const { getTenantDb } = await import("$lib/server/db");
vi.mocked(getTenantDb).mockRejectedValue(new Error("Database connection failed"));
const event = createMockRequest(mockEmailHash);
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBeDefined();
});
it("should handle service errors gracefully", async () => {
const { getTenantDb } = await import("$lib/server/db");
const { AppointmentService } = await import("$lib/server/services/appointment-service");
const mockDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: mockTunnelId }]),
}),
}),
}),
};
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
const mockService = {
getFutureAppointmentsByTunnelId: vi.fn().mockRejectedValue(new Error("Service error")),
};
vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any);
const event = createMockRequest(mockEmailHash);
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBeDefined();
});
});
+1
View File
@@ -2,6 +2,7 @@ import type { RequestEvent } from "@sveltejs/kit";
const ACCESS_TOKEN_COOKIE_NAME = "access_token";
export const getAccessToken = (
// @ts-expect-error exact route names are not relevant here
event: RequestEvent<Partial<Record<string, string>>, string | null>,
): string | null => {
let accessToken: string | null = null;
+9
View File
@@ -0,0 +1,9 @@
ALTER TABLE "client_tunnel_staff_key_share" DROP CONSTRAINT "client_tun_staff_keyShare_tId_client_app_tId_fk";
--> statement-breakpoint
ALTER TABLE "notification" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "notification" ALTER COLUMN "type" SET DEFAULT 'APPOINTMENT_CONFIRMED'::text;--> statement-breakpoint
DROP TYPE "public"."notification_type";--> statement-breakpoint
CREATE TYPE "public"."notification_type" AS ENUM('APPOINTMENT_CONFIRMED', 'APPOINTMENT_CANCELLED');--> statement-breakpoint
ALTER TABLE "notification" ALTER COLUMN "type" SET DEFAULT 'APPOINTMENT_CONFIRMED'::"public"."notification_type";--> statement-breakpoint
ALTER TABLE "notification" ALTER COLUMN "type" SET DATA TYPE "public"."notification_type" USING "type"::"public"."notification_type";--> statement-breakpoint
ALTER TABLE "client_tunnel_staff_key_share" ADD CONSTRAINT "tunnel_staff_key_share_tid_appointment_tid_fk" FOREIGN KEY ("tunnel_id") REFERENCES "public"."client_appointment_tunnel"("id") ON DELETE no action ON UPDATE no action;
@@ -0,0 +1 @@
ALTER TYPE "public"."notification_type" ADD VALUE 'APPOINTMENT_REQUESTED';
+898
View File
@@ -0,0 +1,898 @@
{
"id": "b141a31e-20fd-4247-bb21-e403af1809bc",
"prevId": "cad75f53-2b5a-4594-b978-f3fd8a77ed8e",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.agent": {
"name": "agent",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"descriptions": {
"name": "descriptions",
"type": "json",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "varchar(250000)",
"primaryKey": false,
"notNull": false
},
"archived": {
"name": "archived",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.agent_absence": {
"name": "agent_absence",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"start_date": {
"name": "start_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"end_date": {
"name": "end_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"absence_type": {
"name": "absence_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"agent_absence_agent_id_agent_id_fk": {
"name": "agent_absence_agent_id_agent_id_fk",
"tableFrom": "agent_absence",
"tableTo": "agent",
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointment": {
"name": "appointment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tunnel_id": {
"name": "tunnel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"appointment_date": {
"name": "appointment_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"expiry_date": {
"name": "expiry_date",
"type": "date",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "appointment_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"encrypted_data": {
"name": "encrypted_data",
"type": "text",
"primaryKey": false,
"notNull": false
},
"data_key": {
"name": "data_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"encrypted_payload": {
"name": "encrypted_payload",
"type": "text",
"primaryKey": false,
"notNull": false
},
"iv": {
"name": "iv",
"type": "text",
"primaryKey": false,
"notNull": false
},
"auth_tag": {
"name": "auth_tag",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"appointment_tunnel_id_client_appointment_tunnel_id_fk": {
"name": "appointment_tunnel_id_client_appointment_tunnel_id_fk",
"tableFrom": "appointment",
"tableTo": "client_appointment_tunnel",
"columnsFrom": ["tunnel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"appointment_channel_id_channel_id_fk": {
"name": "appointment_channel_id_channel_id_fk",
"tableFrom": "appointment",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"appointment_agent_id_agent_id_fk": {
"name": "appointment_agent_id_agent_id_fk",
"tableFrom": "appointment",
"tableTo": "agent",
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointment_key_share": {
"name": "appointment_key_share",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"appointment_id": {
"name": "appointment_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"encrypted_key": {
"name": "encrypted_key",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"appointment_key_share_appointment_id_appointment_id_fk": {
"name": "appointment_key_share_appointment_id_appointment_id_fk",
"tableFrom": "appointment_key_share",
"tableTo": "appointment",
"columnsFrom": ["appointment_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.auth_challenge": {
"name": "auth_challenge",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"challenge": {
"name": "challenge",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_hash": {
"name": "email_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"consumed": {
"name": "consumed",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel": {
"name": "channel",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"names": {
"name": "names",
"type": "json",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"paused": {
"name": "paused",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"descriptions": {
"name": "descriptions",
"type": "json",
"primaryKey": false,
"notNull": true
},
"is_public": {
"name": "is_public",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"requires_confirmation": {
"name": "requires_confirmation",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"archived": {
"name": "archived",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel_agent": {
"name": "channel_agent",
"schema": "",
"columns": {
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"channel_agent_channel_id_channel_id_fk": {
"name": "channel_agent_channel_id_channel_id_fk",
"tableFrom": "channel_agent",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"channel_agent_agent_id_agent_id_fk": {
"name": "channel_agent_agent_id_agent_id_fk",
"tableFrom": "channel_agent",
"tableTo": "agent",
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel_slot_template": {
"name": "channel_slot_template",
"schema": "",
"columns": {
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"slot_template_id": {
"name": "slot_template_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"channel_slot_template_channel_id_channel_id_fk": {
"name": "channel_slot_template_channel_id_channel_id_fk",
"tableFrom": "channel_slot_template",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"channel_slot_template_slot_template_id_slotTemplate_id_fk": {
"name": "channel_slot_template_slot_template_id_slotTemplate_id_fk",
"tableFrom": "channel_slot_template",
"tableTo": "slotTemplate",
"columnsFrom": ["slot_template_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel_staff": {
"name": "channel_staff",
"schema": "",
"columns": {
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"staff_id": {
"name": "staff_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"channel_staff_channel_id_channel_id_fk": {
"name": "channel_staff_channel_id_channel_id_fk",
"tableFrom": "channel_staff",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client_appointment_tunnel": {
"name": "client_appointment_tunnel",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email_hash": {
"name": "email_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"client_public_key": {
"name": "client_public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private_key_share": {
"name": "private_key_share",
"type": "text",
"primaryKey": false,
"notNull": true
},
"client_key_share": {
"name": "client_key_share",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"client_appointment_tunnel_email_hash_unique": {
"name": "client_appointment_tunnel_email_hash_unique",
"nullsNotDistinct": false,
"columns": ["email_hash"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client_pin_reset_token": {
"name": "client_pin_reset_token",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"token": {
"name": "token",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"email_hash": {
"name": "email_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"used": {
"name": "used",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"client_pin_reset_token_token_unique": {
"name": "client_pin_reset_token_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client_tunnel_staff_key_share": {
"name": "client_tunnel_staff_key_share",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tunnel_id": {
"name": "tunnel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"encrypted_tunnel_key": {
"name": "encrypted_tunnel_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": {
"name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk",
"tableFrom": "client_tunnel_staff_key_share",
"tableTo": "client_appointment_tunnel",
"columnsFrom": ["tunnel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notification": {
"name": "notification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"staff_id": {
"name": "staff_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "notification_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'APPOINTMENT_CONFIRMED'"
},
"meta_data": {
"name": "meta_data",
"type": "json",
"primaryKey": false,
"notNull": false
},
"is_read": {
"name": "is_read",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.slotTemplate": {
"name": "slotTemplate",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"weekdays": {
"name": "weekdays",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"from": {
"name": "from",
"type": "time",
"primaryKey": false,
"notNull": true
},
"to": {
"name": "to",
"type": "time",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.staff_crypto": {
"name": "staff_crypto",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private_key_share": {
"name": "private_key_share",
"type": "text",
"primaryKey": false,
"notNull": true
},
"passkey_id": {
"name": "passkey_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.appointment_status": {
"name": "appointment_status",
"schema": "public",
"values": ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"]
},
"public.notification_type": {
"name": "notification_type",
"schema": "public",
"values": ["APPOINTMENT_CONFIRMED", "APPOINTMENT_CANCELLED"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+898
View File
@@ -0,0 +1,898 @@
{
"id": "a189259c-e77c-4561-8a6c-c2f2fe5c9eb4",
"prevId": "b141a31e-20fd-4247-bb21-e403af1809bc",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.agent": {
"name": "agent",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"descriptions": {
"name": "descriptions",
"type": "json",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "varchar(250000)",
"primaryKey": false,
"notNull": false
},
"archived": {
"name": "archived",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.agent_absence": {
"name": "agent_absence",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"start_date": {
"name": "start_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"end_date": {
"name": "end_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"absence_type": {
"name": "absence_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"agent_absence_agent_id_agent_id_fk": {
"name": "agent_absence_agent_id_agent_id_fk",
"tableFrom": "agent_absence",
"tableTo": "agent",
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointment": {
"name": "appointment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tunnel_id": {
"name": "tunnel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"appointment_date": {
"name": "appointment_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"expiry_date": {
"name": "expiry_date",
"type": "date",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "appointment_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"encrypted_data": {
"name": "encrypted_data",
"type": "text",
"primaryKey": false,
"notNull": false
},
"data_key": {
"name": "data_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"encrypted_payload": {
"name": "encrypted_payload",
"type": "text",
"primaryKey": false,
"notNull": false
},
"iv": {
"name": "iv",
"type": "text",
"primaryKey": false,
"notNull": false
},
"auth_tag": {
"name": "auth_tag",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"appointment_tunnel_id_client_appointment_tunnel_id_fk": {
"name": "appointment_tunnel_id_client_appointment_tunnel_id_fk",
"tableFrom": "appointment",
"tableTo": "client_appointment_tunnel",
"columnsFrom": ["tunnel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"appointment_channel_id_channel_id_fk": {
"name": "appointment_channel_id_channel_id_fk",
"tableFrom": "appointment",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"appointment_agent_id_agent_id_fk": {
"name": "appointment_agent_id_agent_id_fk",
"tableFrom": "appointment",
"tableTo": "agent",
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointment_key_share": {
"name": "appointment_key_share",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"appointment_id": {
"name": "appointment_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"encrypted_key": {
"name": "encrypted_key",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"appointment_key_share_appointment_id_appointment_id_fk": {
"name": "appointment_key_share_appointment_id_appointment_id_fk",
"tableFrom": "appointment_key_share",
"tableTo": "appointment",
"columnsFrom": ["appointment_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.auth_challenge": {
"name": "auth_challenge",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"challenge": {
"name": "challenge",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_hash": {
"name": "email_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"consumed": {
"name": "consumed",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel": {
"name": "channel",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"names": {
"name": "names",
"type": "json",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"paused": {
"name": "paused",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"descriptions": {
"name": "descriptions",
"type": "json",
"primaryKey": false,
"notNull": true
},
"is_public": {
"name": "is_public",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"requires_confirmation": {
"name": "requires_confirmation",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"archived": {
"name": "archived",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel_agent": {
"name": "channel_agent",
"schema": "",
"columns": {
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"channel_agent_channel_id_channel_id_fk": {
"name": "channel_agent_channel_id_channel_id_fk",
"tableFrom": "channel_agent",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"channel_agent_agent_id_agent_id_fk": {
"name": "channel_agent_agent_id_agent_id_fk",
"tableFrom": "channel_agent",
"tableTo": "agent",
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel_slot_template": {
"name": "channel_slot_template",
"schema": "",
"columns": {
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"slot_template_id": {
"name": "slot_template_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"channel_slot_template_channel_id_channel_id_fk": {
"name": "channel_slot_template_channel_id_channel_id_fk",
"tableFrom": "channel_slot_template",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"channel_slot_template_slot_template_id_slotTemplate_id_fk": {
"name": "channel_slot_template_slot_template_id_slotTemplate_id_fk",
"tableFrom": "channel_slot_template",
"tableTo": "slotTemplate",
"columnsFrom": ["slot_template_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel_staff": {
"name": "channel_staff",
"schema": "",
"columns": {
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"staff_id": {
"name": "staff_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"channel_staff_channel_id_channel_id_fk": {
"name": "channel_staff_channel_id_channel_id_fk",
"tableFrom": "channel_staff",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client_appointment_tunnel": {
"name": "client_appointment_tunnel",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email_hash": {
"name": "email_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"client_public_key": {
"name": "client_public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private_key_share": {
"name": "private_key_share",
"type": "text",
"primaryKey": false,
"notNull": true
},
"client_key_share": {
"name": "client_key_share",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"client_appointment_tunnel_email_hash_unique": {
"name": "client_appointment_tunnel_email_hash_unique",
"nullsNotDistinct": false,
"columns": ["email_hash"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client_pin_reset_token": {
"name": "client_pin_reset_token",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"token": {
"name": "token",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"email_hash": {
"name": "email_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"used": {
"name": "used",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"client_pin_reset_token_token_unique": {
"name": "client_pin_reset_token_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client_tunnel_staff_key_share": {
"name": "client_tunnel_staff_key_share",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tunnel_id": {
"name": "tunnel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"encrypted_tunnel_key": {
"name": "encrypted_tunnel_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": {
"name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk",
"tableFrom": "client_tunnel_staff_key_share",
"tableTo": "client_appointment_tunnel",
"columnsFrom": ["tunnel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notification": {
"name": "notification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"staff_id": {
"name": "staff_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "notification_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'APPOINTMENT_CONFIRMED'"
},
"meta_data": {
"name": "meta_data",
"type": "json",
"primaryKey": false,
"notNull": false
},
"is_read": {
"name": "is_read",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.slotTemplate": {
"name": "slotTemplate",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"weekdays": {
"name": "weekdays",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"from": {
"name": "from",
"type": "time",
"primaryKey": false,
"notNull": true
},
"to": {
"name": "to",
"type": "time",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.staff_crypto": {
"name": "staff_crypto",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private_key_share": {
"name": "private_key_share",
"type": "text",
"primaryKey": false,
"notNull": true
},
"passkey_id": {
"name": "passkey_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.appointment_status": {
"name": "appointment_status",
"schema": "public",
"values": ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"]
},
"public.notification_type": {
"name": "notification_type",
"schema": "public",
"values": ["APPOINTMENT_CONFIRMED", "APPOINTMENT_CANCELLED", "APPOINTMENT_REQUESTED"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+14
View File
@@ -78,6 +78,20 @@
"when": 1768324220281,
"tag": "0010_tiny_angel",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1768926072442,
"tag": "0011_kind_starbolt",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1769078158060,
"tag": "0012_military_puppet_master",
"breakpoints": true
}
]
}