Merge branch 'main' of github.com:open-reception/appointment-booking-software into 153-book-appointment-allow-clients-to-select-if-they-want-email-notifications

This commit is contained in:
Hendrik Belitz
2026-01-21 17:16:10 +01:00
39 changed files with 140 additions and 137 deletions
+3 -3
View File
@@ -4214,9 +4214,9 @@
}
},
"node_modules/devalue": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz",
"integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==",
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
"license": "MIT"
},
"node_modules/dlv": {
+9 -2
View File
@@ -1,6 +1,7 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"welcome": "Willkommen bei OpenReception",
"home": "Startseite",
"poweredBy": "Betrieben mit",
"i18n": {
"label": "Sprache wählen",
@@ -108,9 +109,13 @@
"action": "Passkey setzen",
"success": "Passkey eingerichtet. Logge Dich damit jetzt ein.",
"successKeyPairSaved": "Schlüsselpaar erfolgreich gespeichert.",
"confirmPrfRetrival": "Jetzt verwende den neu erstellten Passkey, um damit die Verschlüsselung zu aktivieren.",
"error": "Passkey konnte nicht gesetzt werden",
"errorKeyPairDataMissing": "Konnte Schlüsselpaar nicht generieren. Fehlende Daten.",
"errorKeyPairNotSaved": "Schlüsselpaar konnte nicht gespeichert werden."
"errorKeyPairNotSaved": "Schlüsselpaar konnte nicht gespeichert werden.",
"errorPrfOutputNotTriggered": "Sie müssen der Verwendung des Passkeys zustimmen.",
"errorAuthenticatorNotSupported": "Dieser Authentifikator unterstützt nicht die erforderliche Sicherheitserweiterung (PRF). Bitte verwenden Sie einen modernen Authentifikator wie YubiKey 5.2.3+, Apple Touch ID oder Android.",
"errorGettingPrfOutput": "Fehler beim Abrufen der Sicherheitsdaten vom Passkey. Bitte verwenden Sie einen modernen Authentifikator, der die PRF-Erweiterung unterstützt."
},
"logout": {
"title": "Erfolgreich abgemeldet",
@@ -694,7 +699,8 @@
"title": "Telefonnummer erforderlich",
"label": "Kunden müssen eine Telefonnummer angeben, wenn sie einen Termin buchen."
}
}
},
"action": "Speichern"
}
},
"account": {
@@ -901,6 +907,7 @@
"today": "Heute",
"loading": "Loading calendar",
"decrypting": "wird entschlüsselt...",
"decryptingError": "Entschlüsselung fehlgeschlagen. Bitte neu anmelden.",
"shownAppointments": {
"title": "Termine",
"options": {
+9 -2
View File
@@ -1,6 +1,7 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"welcome": "Welcome to OpenReception",
"home": "Home",
"poweredBy": "Powered by",
"i18n": {
"label": "Select Language",
@@ -41,9 +42,13 @@
"action": "Set Passkey",
"success": "Passkey set. Login with it now.",
"successKeyPairSaved": "Key pair saved successfully.",
"confirmPrfRetrival": "Now use your newly created passkey to enable encryption with it.",
"error": "Passkey could not be set",
"errorKeyPairDataMissing": "Could not generate key pair. Missing data.",
"errorKeyPairNotSaved": "Key pair could not be saved."
"errorKeyPairNotSaved": "Key pair could not be saved.",
"errorPrfOutputNotTriggered": "You have to confirm using the passkey.",
"errorAuthenticatorNotSupported": "This authenticator does not support the required security extension (PRF). Please use a modern authenticator like YubiKey 5.2.3+, Apple Touch ID or Android.",
"errorGettingPrfOutput": "Failed to retrieve security data from passkey. Please use a modern authenticator that supports PRF extension."
},
"slogan": "End-to-End encrypted appointment booking platform.",
"logo": "OpenReception Logo: A 3D pixel art with calendar that's also a lock",
@@ -703,7 +708,8 @@
"title": "Require Phone Number",
"label": "Require clients to provide a phone number when booking an appointment."
}
}
},
"action": "Save"
}
},
"account": {
@@ -910,6 +916,7 @@
"today": "Today",
"loading": "Loading calendar",
"decrypting": "decrypting...",
"decryptingError": "Unable to decrypt data. Please log-in again",
"shownAppointments": {
"title": "Appointments",
"options": {
@@ -29,7 +29,7 @@
);
</script>
{#if locales && options.length > 1}
{#if options.length > 1}
<div class={className}>
<ComboBox
{options}
@@ -9,6 +9,7 @@ import { eq, and, not, count, or } from "drizzle-orm";
import logger from "$lib/logger";
import { z } from "zod";
import { ValidationError, NotFoundError, ConflictError } from "../utils/errors";
import { redactDbUrl } from "../utils/url";
if (!env.DATABASE_URL) throw new Error("DATABASE_URL is not set");
@@ -110,7 +111,7 @@ export class TenantAdminService {
} catch (dbError) {
log.error("Failed to initialize tenant database", {
tenantId: tenant[0].id,
databaseUrl: newTenant.databaseUrl,
databaseUrl: redactDbUrl(newTenant.databaseUrl),
error: String(dbError),
});
+6
View File
@@ -0,0 +1,6 @@
export const redactDbUrl = (input: string) => {
const url = new URL(input);
url.username = "redacted-user";
url.password = "redacted-pw";
return url.toString();
};
@@ -85,7 +85,7 @@
<li class="mb-3">
<Button
variant="outline"
class="flex h-auto w-full cursor-pointer items-start gap-9 px-3 py-2.5 text-left"
class="hover:border-primary/30 flex h-auto w-full cursor-pointer items-start gap-9 px-3 py-2.5 text-left"
onclick={() => selectAgent(null)}
>
<div class="relative size-10">
@@ -27,7 +27,7 @@
<li>
<Button
variant="outline"
class="flex h-auto w-full cursor-pointer flex-col items-start gap-1 px-3 py-2.5 text-left"
class="hover:border-primary/30 flex h-auto w-full cursor-pointer flex-col items-start gap-1 px-3 py-2.5 text-left"
onclick={() =>
proceed({
...appointment,
@@ -35,7 +35,7 @@
appointment.slot.duration,
tenant.id,
Boolean(appointment.isNewClient),
getLocale() || "de",
getLocale() || "en",
)
.then(() => {
const isRequest = channel.requiresConfirmation;
@@ -61,7 +61,7 @@
{m["public.steps.summary.request.hint"]()}
</Text>
{/if}
<Button isLoading={isSubmitting} onclick={onSubmit}>
<Button isLoading={isSubmitting} disabled={isSubmitting} onclick={onSubmit}>
{#if channel.requiresConfirmation}
{m["public.steps.summary.request.action"]()}
{:else}
@@ -35,7 +35,7 @@
let passkeyId: string | undefined = $state();
let prfOutput: ArrayBuffer | undefined = $state();
let kyberKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array } | undefined = $state();
let registrationChallenge: string | undefined = $state(); // Store first challenge for form submission
let registrationChallenge: string | undefined = $state();
const form = superForm(data.form, {
validators: zodClient(formSchema),
@@ -111,9 +111,7 @@
email: $formData.email,
extensions: extensionResults,
});
toast.error(
"This authenticator does not support the required security extension (PRF). Please use a modern authenticator like YubiKey 5.2.3+, Titan Gen2, Windows Hello, Touch ID, or Android.",
);
toast.warning(m["setupPasskey.errorAuthenticatorNotSupported"]());
return;
}
@@ -121,6 +119,15 @@
const attestationObjectResp = passkeyResp.response.attestationObject;
const clientDataJSONResp = passkeyResp.response.clientDataJSON;
// UX Primer for second passkey UI
const isConfirmed = confirm(m["setupPasskey.confirmPrfRetrival"]());
if (!isConfirmed) {
$passkeyLoading = "error";
toast.error(m["setupPasskey.errorPrfOutputNotTriggered"]());
logger.warn("PRF challenge primer not confirmed");
return;
}
// CRITICAL: Get PRF output immediately after passkey creation
// This is the only time we can retrieve the PRF output
// Uses email as salt for multi-passkey support
@@ -149,9 +156,7 @@
passkeyId: passkeyResp.id,
error,
});
toast.error(
"Failed to retrieve security data from passkey. Please use a modern authenticator that supports PRF extension.",
);
toast.error(m["setupPasskey.errorGettingPrfOutput"]());
return;
}
+1 -1
View File
@@ -48,7 +48,7 @@
};
const initStaffCrypto = async () => {
if (data?.user?.id && data?.user?.tenantId) {
if (data?.user?.id && data?.user?.tenantId && data?.user.role !== "GLOBAL_ADMIN") {
// Try to initialize from session storage (from login)
const success = await staffCrypto.initFromSession(data.user.id, data.user.tenantId);
if (success) {
@@ -1,5 +1,4 @@
<script lang="ts">
import { invalidate } from "$app/navigation";
import { m } from "$i18n/messages";
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
@@ -61,10 +60,9 @@
{/snippet}
<AddAgentForm
done={() => {
tenants.reload();
agents.load();
channels.load();
tenants.reload();
invalidate(ROUTES.DASHBOARD.AGENTS);
closeDialog("add");
}}
/>
@@ -119,7 +117,6 @@
curItem = null;
agents.load();
channels.load();
invalidate(ROUTES.DASHBOARD.AGENTS);
}}
/>
{/if}
@@ -134,7 +131,6 @@
agents.load();
channels.load();
tenants.reload();
invalidate(ROUTES.DASHBOARD.AGENTS);
}}
/>
{/if}
@@ -84,7 +84,9 @@
{#if error}
<div class="text-destructive flex h-full items-center gap-1 px-1">
<Text style="xs" class="leading-none" title={error}>⚠️</Text>
<Text style="xs" class="leading-none" title={error}>
⚠️ {m["calendar.decryptingError"]()}
</Text>
</div>
{:else if decrypted === undefined}
<div class="flex h-full items-center gap-1 px-1">
@@ -1,5 +1,4 @@
<script lang="ts">
import { invalidate } from "$app/navigation";
import { m } from "$i18n/messages";
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
@@ -23,6 +22,7 @@
import DeleteChannelForm from "./(components)/delete-channel-form/delete-channel-form.svelte";
import EditChannelForm from "./(components)/edit-channel-form/edit-channel-form.svelte";
import { PauseChannelForm } from "./(components)/pause-channel-form";
import { channels } from "$lib/stores/channels";
const { data } = $props();
let curItem: TChannelWithFullAgents | null = $state(null);
@@ -63,7 +63,7 @@
<AddChannelForm
done={() => {
tenants.reload();
invalidate(ROUTES.DASHBOARD.CHANNELS);
channels.load();
closeDialog("add");
}}
/>
@@ -127,7 +127,7 @@
done={() => {
closeDialog("edit");
curItem = null;
invalidate(ROUTES.DASHBOARD.CHANNELS);
channels.load();
}}
/>
{/if}
@@ -143,7 +143,7 @@
done={() => {
closeDialog("pause");
curItem = null;
invalidate(ROUTES.DASHBOARD.CHANNELS);
channels.load();
}}
/>
</ResponsiveDialog>
@@ -156,7 +156,7 @@
closeDialog("delete");
curItem = null;
tenants.reload();
invalidate(ROUTES.DASHBOARD.CHANNELS);
channels.load();
}}
/>
{/if}
@@ -333,7 +333,7 @@
disabled={isSubmitting}
class="sm:ml-auto"
>
{m["tenants.edit.action"]()}
{m["settings.form.action"]()}
</Form.Button>
</div>
</Form.Root>
@@ -1,6 +1,9 @@
import { m } from "$i18n/messages";
import { z } from "zod";
const optionalUrl = (errorMessage: string) =>
z.union([z.literal(""), z.url({ message: errorMessage })]).optional();
export const formSchema = z
.object({
id: z.string(),
@@ -18,9 +21,9 @@ export const formSchema = z
city: z.string().min(1, { message: m["form.errors.city"]() }),
}),
links: z.object({
website: z.string().url({ message: m["form.errors.url"]() }).optional().default(""),
imprint: z.string().url({ message: m["form.errors.url"]() }).optional().default(""),
privacyStatement: z.string().url({ message: m["form.errors.url"]() }).optional().default(""),
website: optionalUrl(m["form.errors.url"]()),
imprint: optionalUrl(m["form.errors.url"]()),
privacyStatement: optionalUrl(m["form.errors.url"]()),
}),
settings: z.object({
autoDeleteDays: z.number().min(30, { message: m["form.errors.deleteAfterDays"]() }),
@@ -4,7 +4,6 @@
import { Input } from "$lib/components/ui/input";
import * as Select from "$lib/components/ui/select";
import { supportedLocales, translatedLocales } from "$lib/const/locales";
import { auth } from "$lib/stores/auth";
import { tenants } from "$lib/stores/tenants";
import type { TStaff } from "$lib/types/users";
import { toast } from "svelte-sonner";
@@ -18,10 +17,7 @@
let { done }: { done: () => void } = $props();
const tenantLocales = get(tenants).currentTenant?.languages ?? [];
const userRole = $derived($auth.user?.role ?? "STAFF");
const availableRolesForUser = $derived(
userRole === "GLOBAL_ADMIN" ? roles : roles.filter((it) => it.value !== "GLOBAL_ADMIN"),
);
const availableRoles = $derived(roles.filter((it) => it.value !== "GLOBAL_ADMIN"));
const form = superForm(
{
name: "",
@@ -114,7 +110,7 @@
{value ? value : m["staff.form.fields.role.placeholder"]()}
</Select.Trigger>
<Select.Content>
{#each availableRolesForUser as role (role.value)}
{#each availableRoles as role (role.value)}
<Select.Item value={role.value}>
{role.label}
</Select.Item>
@@ -3,7 +3,6 @@
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import * as Select from "$lib/components/ui/select";
import { auth } from "$lib/stores/auth";
import type { TStaff } from "$lib/types/users";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
@@ -14,10 +13,7 @@
let { entity, done }: { entity: TStaff; done: () => void } = $props();
const userRole = $derived($auth.user?.role ?? "STAFF");
const availableRolesForUser = $derived(
userRole === "GLOBAL_ADMIN" ? roles : roles.filter((it) => it.value !== "GLOBAL_ADMIN"),
);
const availableRoles = $derived(roles.filter((it) => it.value !== "GLOBAL_ADMIN"));
const form = superForm(
{
id: entity.id,
@@ -80,7 +76,7 @@
{value ? value : m["staff.form.fields.role.placeholder"]()}
</Select.Trigger>
<Select.Content>
{#each availableRolesForUser as role (role.value)}
{#each availableRoles as role (role.value)}
<Select.Item value={role.value}>
{role.label}
</Select.Item>
+1 -7
View File
@@ -1,7 +1,7 @@
import { fail } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms";
import { zod4 as zod } from "sveltekit-superforms/adapters";
import type { Actions, PageServerLoad } from "./$types";
import type { Actions } from "./$types";
import { formSchema } from "./schema";
import type { WebAuthnCredential } from "$lib/server/auth/webauthn-service";
import type { TUser } from "$lib/types/user";
@@ -9,12 +9,6 @@ import logger from "$lib/logger";
const log = logger.setContext(import.meta.filename);
export const load: PageServerLoad = async () => {
return {
form: await superValidate(zod(formSchema)),
};
};
export const actions: Actions = {
default: async (event) => {
const form = await superValidate(event, zod(formSchema));
+6 -3
View File
@@ -5,8 +5,8 @@
import { PageWithClaim } from "$lib/components/ui/page";
import LoginForm from "./login-form.svelte";
import type { EventReporter } from "$lib/components/ui/form/form-root.svelte";
const { data } = $props();
import { Button } from "$lib/components/ui/button";
import { ROUTES } from "$lib/const/routes";
const formId = "create-account-form";
let isSubmitting = $state(false);
@@ -26,6 +26,9 @@
</svelte:head>
<PageWithClaim isWithLanguageSwitch>
{#snippet left()}
<Button href={ROUTES.MAIN} size="sm" variant="link">{m["home"]()}</Button>
{/snippet}
<CenteredCard.Root>
<CenteredCard.Header>
<CenteredCard.Title>
@@ -36,7 +39,7 @@
</CenteredCard.Description>
</CenteredCard.Header>
<CenteredCard.Main>
<LoginForm {formId} {data} {onEvent} />
<LoginForm {formId} {onEvent} />
</CenteredCard.Main>
<CenteredCard.Action>
<Form.Button
+38 -53
View File
@@ -20,31 +20,37 @@
import { auth } from "$lib/stores/auth";
import logger from "$lib/logger";
let {
data,
formId,
onEvent,
}: { formId: string; onEvent: EventReporter; data: { form: SuperValidated<Infer<FormSchema>> } } =
$props();
let { formId, onEvent }: { formId: string; onEvent: EventReporter } = $props();
const form = superForm(data.form, {
validators: zodClient(formSchema),
onChange: (event) => {
if (event.paths.includes("email")) {
setProperPasskeyState();
}
const form = superForm(
{
email: "",
type: "passkey",
id: "",
passphrase: "passphrase",
clientDataBase64: "",
authenticatorDataBase64: "",
signatureBase64: "",
},
onResult: async (event) => {
if (event.result.type === "success") {
auth.setUser(event.result.data?.user);
await goto(ROUTES.DASHBOARD.MAIN);
} else {
toast.error(m["login.error"]());
}
onEvent({ isSubmitting: false });
{
validators: zodClient(formSchema),
onChange: (event) => {
if (event.paths.includes("email")) {
setProperPasskeyState();
}
},
onResult: async (event) => {
if (event.result.type === "success") {
auth.setUser(event.result.data?.user);
await goto(ROUTES.DASHBOARD.MAIN);
} else {
toast.error(m["login.error"]());
}
onEvent({ isSubmitting: false });
},
onSubmit: () => onEvent({ isSubmitting: true }),
},
onSubmit: () => onEvent({ isSubmitting: true }),
});
);
onMount(() => {
$formData.type = "passkey";
@@ -186,14 +192,13 @@
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.passphrase"]()}</Form.Label>
<!-- prettier-ignore -->
<Input
{...props}
bind:value={($formData as FormDataPassphrase).passphrase}
type="password"
minlength={30}
maxlength={100}
/>
{...props}
bind:value={$formData.passphrase}
type="password"
minlength={30}
maxlength={100}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
@@ -210,48 +215,28 @@
<Form.Field {form} name="id" class="hidden">
<Form.Control>
{#snippet children({ props })}
<!-- prettier-ignore -->
<Input
{...props}
bind:value={($formData as FormDataPasskey).id}
type="hidden"
/>
<Input {...props} bind:value={$formData.id} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
<Form.Field {form} name="authenticatorDataBase64" class="hidden">
<Form.Control>
{#snippet children({ props })}
<!-- prettier-ignore -->
<Input
{...props}
bind:value={($formData as FormDataPasskey).authenticatorDataBase64}
type="hidden"
/>
<Input {...props} bind:value={$formData.authenticatorDataBase64} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
<Form.Field {form} name="clientDataBase64" class="hidden">
<Form.Control>
{#snippet children({ props })}
<!-- prettier-ignore -->
<Input
{...props}
bind:value={($formData as FormDataPasskey).clientDataBase64}
type="hidden"
/>
<Input {...props} bind:value={$formData.clientDataBase64} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
<Form.Field {form} name="signatureBase64" class="hidden">
<Form.Control>
{#snippet children({ props })}
<!-- prettier-ignore -->
<Input
{...props}
bind:value={($formData as FormDataPasskey).signatureBase64}
type="hidden"
/>
<Input {...props} bind:value={$formData.signatureBase64} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
+1 -1
View File
@@ -97,7 +97,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => {
secure: true,
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24 * 7, // 7 days
maxAge: 60 * 15,
});
logger.info("Tenant switched successfully", {
+1 -1
View File
@@ -16,7 +16,7 @@ const inviteUserSchema = z.object({
name: z.string().min(1),
role: z.enum(["TENANT_ADMIN", "STAFF"]),
tenantId: z.string().uuid(),
language: z.enum(["de", "en"]).optional().default("de"),
language: z.enum(["de", "en"]).optional().default("en"),
});
registerOpenAPIRoute("/auth/invite", "POST", {
+1 -1
View File
@@ -279,7 +279,7 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress,
secure: true,
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24 * 7, // 7 days
expires: sessionData.expiresAt,
});
logger.info("Login successful", {
+1 -1
View File
@@ -102,7 +102,7 @@ export const POST: RequestHandler = async ({ cookies }) => {
secure: true,
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24 * 7, // 7 days
expires: result.expiresAt,
});
return json({
@@ -9,7 +9,7 @@ import { checkPermission } from "$lib/server/utils/permissions";
const requestSchema = z.object({
clientEmail: z.string().email().optional(),
clientLanguage: z.string().optional().default("de"),
clientLanguage: z.string().optional().default("en"),
});
// Register OpenAPI documentation for PUT
@@ -28,7 +28,7 @@ const requestSchema = z.object({
appointmentDate: z.string(),
duration: z.number().int().positive(),
clientEmail: z.string().email(),
clientLanguage: z.string().optional().default("de"),
clientLanguage: z.string().optional().default("en"),
encryptedAppointment: z.object({
encryptedPayload: z.string(),
iv: z.string(),
@@ -13,7 +13,7 @@ const requestSchema = z.object({
duration: z.number().int().positive(),
emailHash: z.string(),
clientEmail: z.string().email(),
clientLanguage: z.string().optional().default("de"),
clientLanguage: z.string().optional().default("en"),
clientPublicKey: z.string(),
privateKeyShare: z.string(),
encryptedAppointment: z.object({
+4 -1
View File
@@ -122,7 +122,10 @@ export const GET: RequestHandler = async ({ params, locals }) => {
try {
const staff = await StaffService.getStaffMembers(tenantId);
return json({ staff: staff });
const filteredStaff = staff.filter((member) => member.role !== "GLOBAL_ADMIN");
return json({ staff: filteredStaff });
} catch (error) {
logError(log)("Error fetching staff data", error, locals.user?.id, params.id);
+1 -1
View File
@@ -61,7 +61,7 @@ ALTER TABLE "appointment" ADD COLUMN "auth_tag" text;--> statement-breakpoint
ALTER TABLE "appointment" ADD COLUMN "created_at" timestamp DEFAULT now();--> statement-breakpoint
ALTER TABLE "appointment" ADD COLUMN "updated_at" timestamp DEFAULT now();--> statement-breakpoint
ALTER TABLE "appointment_key_share" ADD CONSTRAINT "appointment_key_share_appointment_id_appointment_id_fk" FOREIGN KEY ("appointment_id") REFERENCES "public"."appointment"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "client_tunnel_staff_key_share" ADD CONSTRAINT "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk" FOREIGN KEY ("tunnel_id") REFERENCES "public"."client_appointment_tunnel"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "client_tunnel_staff_key_share" ADD CONSTRAINT "client_tun_staff_keyShare_tId_client_app_tId_fk" FOREIGN KEY ("tunnel_id") REFERENCES "public"."client_appointment_tunnel"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "appointment" ADD CONSTRAINT "appointment_tunnel_id_client_appointment_tunnel_id_fk" FOREIGN KEY ("tunnel_id") REFERENCES "public"."client_appointment_tunnel"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "agent" DROP COLUMN "description";--> statement-breakpoint
ALTER TABLE "appointment" DROP COLUMN "client_id";--> statement-breakpoint
-1
View File
@@ -1,6 +1,5 @@
CREATE TYPE "public"."notification_type" AS ENUM('APPOINTMENT_CONFIRMED', 'APPOINTMENT_CANCELED');--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "type" "notification_type" DEFAULT 'APPOINTMENT_CONFIRMED' NOT NULL;--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "meta_data" json;--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint
ALTER TABLE "notification" DROP COLUMN "title";--> statement-breakpoint
ALTER TABLE "notification" DROP COLUMN "description";
+2 -2
View File
@@ -620,8 +620,8 @@
},
"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",
"client_tun_staff_keyShare_tId_client_app_tId_fk": {
"name": "client_tun_staff_keyShare_tId_client_app_tId_fk",
"tableFrom": "client_tunnel_staff_key_share",
"tableTo": "client_appointment_tunnel",
"columnsFrom": ["tunnel_id"],
+2 -2
View File
@@ -635,8 +635,8 @@
},
"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",
"client_tun_staff_keyShare_tId_client_app_tId_fk": {
"name": "client_tun_staff_keyShare_tId_client_app_tId_fk",
"tableFrom": "client_tunnel_staff_key_share",
"tableTo": "client_appointment_tunnel",
"columnsFrom": ["tunnel_id"],
+2 -2
View File
@@ -649,8 +649,8 @@
},
"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",
"client_tun_staff_keyShare_tId_client_app_tId_fk": {
"name": "client_tun_staff_keyShare_tId_client_app_tId_fk",
"tableFrom": "client_tunnel_staff_key_share",
"tableTo": "client_appointment_tunnel",
"columnsFrom": ["tunnel_id"],
+2 -2
View File
@@ -655,8 +655,8 @@
},
"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",
"client_tun_staff_keyShare_tId_client_app_tId_fk": {
"name": "client_tun_staff_keyShare_tId_client_app_tId_fk",
"tableFrom": "client_tunnel_staff_key_share",
"tableTo": "client_appointment_tunnel",
"columnsFrom": ["tunnel_id"],
+2 -2
View File
@@ -599,8 +599,8 @@
},
"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",
"client_tun_staff_keyShare_tId_client_app_tId_fk": {
"name": "client_tun_staff_keyShare_tId_client_app_tId_fk",
"tableFrom": "client_tunnel_staff_key_share",
"tableTo": "client_appointment_tunnel",
"columnsFrom": ["tunnel_id"],
+2 -2
View File
@@ -634,8 +634,8 @@
},
"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",
"client_tun_staff_keyShare_tId_client_app_tId_fk": {
"name": "client_tun_staff_keyShare_tId_client_app_tId_fk",
"tableFrom": "client_tunnel_staff_key_share",
"tableTo": "client_appointment_tunnel",
"columnsFrom": ["tunnel_id"],
+2 -2
View File
@@ -634,8 +634,8 @@
},
"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",
"client_tun_staff_keyShare_tId_client_app_tId_fk": {
"name": "client_tun_staff_keyShare_tId_client_app_tId_fk",
"tableFrom": "client_tunnel_staff_key_share",
"tableTo": "client_appointment_tunnel",
"columnsFrom": ["tunnel_id"],
+2 -2
View File
@@ -693,8 +693,8 @@
},
"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",
"client_tun_staff_keyShare_tId_client_app_tId_fk": {
"name": "client_tun_staff_keyShare_tId_client_app_tId_fk",
"tableFrom": "client_tunnel_staff_key_share",
"tableTo": "client_appointment_tunnel",
"columnsFrom": ["tunnel_id"],