mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-10-01 21:24:51 +02:00
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:
@@ -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:
|
||||
|
||||
Generated
+491
-641
File diff suppressed because it is too large
Load Diff
+22
-22
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -28,4 +28,4 @@ export const ROUTES = {
|
||||
CHANGE_PASSPHRASE: "/dashboard/account/change-passphrase",
|
||||
},
|
||||
},
|
||||
};
|
||||
} as const;
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
cancelUrl: string;
|
||||
} = $props();
|
||||
|
||||
setLocale(locale);
|
||||
$effect(() => {
|
||||
setLocale(locale);
|
||||
});
|
||||
</script>
|
||||
|
||||
<EmailLayout>
|
||||
|
||||
@@ -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>
|
||||
@@ -35,7 +35,9 @@
|
||||
cancelUrl: string;
|
||||
} = $props();
|
||||
|
||||
setLocale(locale);
|
||||
$effect(() => {
|
||||
setLocale(locale);
|
||||
});
|
||||
</script>
|
||||
|
||||
<EmailLayout>
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
};
|
||||
} = $props();
|
||||
|
||||
setLocale(locale);
|
||||
$effect(() => {
|
||||
setLocale(locale);
|
||||
});
|
||||
</script>
|
||||
|
||||
<EmailLayout>
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
expirationMinutes: number;
|
||||
} = $props();
|
||||
|
||||
setLocale(locale);
|
||||
$effect(() => {
|
||||
setLocale(locale);
|
||||
});
|
||||
</script>
|
||||
|
||||
<EmailLayout>
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
loginUrl: string;
|
||||
} = $props();
|
||||
|
||||
setLocale(locale);
|
||||
$effect(() => {
|
||||
setLocale(locale);
|
||||
});
|
||||
</script>
|
||||
|
||||
<EmailLayout>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ export type TPublicAppointment = {
|
||||
duration: number;
|
||||
};
|
||||
data?: {
|
||||
salutation: string;
|
||||
name: string;
|
||||
shareEmail: boolean;
|
||||
email: string;
|
||||
phone?: string;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+44
-15
@@ -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"]());
|
||||
}
|
||||
|
||||
@@ -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" } });
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+1
@@ -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 },
|
||||
{
|
||||
|
||||
+6
-2
@@ -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>
|
||||
|
||||
+1
@@ -14,6 +14,7 @@
|
||||
|
||||
let { entity, done }: { entity: TAgent; done: () => void } = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const form = superForm(
|
||||
{ id: entity.id, name: "" },
|
||||
{
|
||||
|
||||
+4
-2
@@ -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);
|
||||
}
|
||||
|
||||
+1
@@ -15,6 +15,7 @@
|
||||
|
||||
let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const form = superForm(
|
||||
{ id: entity.id, name: "" },
|
||||
{
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+1
@@ -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 },
|
||||
{
|
||||
|
||||
+2
@@ -30,6 +30,8 @@
|
||||
key: it,
|
||||
label: translatedLocales[it as keyof typeof translatedLocales],
|
||||
}));
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const form = superForm(
|
||||
{
|
||||
id: entity.id,
|
||||
|
||||
+1
@@ -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 },
|
||||
{
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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") {
|
||||
|
||||
+1
@@ -17,6 +17,7 @@
|
||||
|
||||
let { entity, done }: { entity: TTenant; done: () => void } = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const form = superForm(
|
||||
{ id: entity.id, shortname: "" },
|
||||
{
|
||||
|
||||
+1
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
+216
@@ -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();
|
||||
}
|
||||
};
|
||||
+245
@@ -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();
|
||||
}
|
||||
};
|
||||
+237
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user