diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json
index 2d106e4..765a8ac 100644
--- a/project.inlang/messages/de.json
+++ b/project.inlang/messages/de.json
@@ -940,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.",
diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json
index 93d30e0..b9beec9 100644
--- a/project.inlang/messages/en.json
+++ b/project.inlang/messages/en.json
@@ -944,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.",
diff --git a/src/lib/emails/AppointmentRejected.svelte b/src/lib/emails/AppointmentRejected.svelte
new file mode 100644
index 0000000..8046107
--- /dev/null
+++ b/src/lib/emails/AppointmentRejected.svelte
@@ -0,0 +1,61 @@
+
+
+
+ {m["emails.greeting"]({ name: user.email })}
+
+ {m["emails.appointmentRejected.introduction"]()}
+
+ {channel}
+
+ {appointment.agentName}
+ {renderAppointmentDate(appointment.appointmentDate, locale)}
+ {renderAppointmentTime(appointment.appointmentDate, locale)}
+ {m["emails.oclock"]()}
+
+ {tenant.longName}
+
+ {address.street}
+ {address.number}
+ {#if address.additionalAddressInfo}{address.additionalAddressInfo}
{/if}
+ {address.zip}
+ {address.city}
+
+
+ {m["emails.appointmentRejected.reason"]()}
+
+
diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts
index 01ac01b..675feff 100644
--- a/src/lib/server/db/tenant-schema.ts
+++ b/src/lib/server/db/tenant-schema.ts
@@ -26,7 +26,11 @@ export const appointmentStatusEnum = pgEnum("appointment_status", [
"NO_SHOW",
]);
-export const notificationTypes = ["APPOINTMENT_CONFIRMED", "APPOINTMENT_CANCELLED"] 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);
diff --git a/src/lib/server/email/email-service.ts b/src/lib/server/email/email-service.ts
index bce0098..19a3c2b 100644
--- a/src/lib/server/email/email-service.ts
+++ b/src/lib/server/email/email-service.ts
@@ -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}
+ */
+export async function sendAppointmentRejectedEmail(
+ user: SelectClient | SelectUser,
+ tenant: SelectTenant,
+ appointment: SelectAppointment,
+ channelTitle?: string,
+): Promise {
+ // 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
diff --git a/src/lib/server/services/__tests__/appointment-service.test.ts b/src/lib/server/services/__tests__/appointment-service.test.ts
index e6ca6ad..6c25f63 100644
--- a/src/lib/server/services/__tests__/appointment-service.test.ts
+++ b/src/lib/server/services/__tests__/appointment-service.test.ts
@@ -25,6 +25,16 @@ vi.mock("../challenge-throttle", () => ({
},
}));
+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",
diff --git a/src/lib/server/services/appointment-service.ts b/src/lib/server/services/appointment-service.ts
index f42548e..be4b704 100644
--- a/src/lib/server/services/appointment-service.ts
+++ b/src/lib/server/services/appointment-service.ts
@@ -11,6 +11,7 @@ import {
sendAppointmentRequestEmail,
sendAppointmentCancelledEmail,
getChannelTitle,
+ sendAppointmentRejectedEmail,
} from "../email/email-service";
import { TenantAdminService } from "./tenant-admin-service";
import { NotificationService } from "./notification-service";
@@ -127,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", {
@@ -206,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 {
+ 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 {
const log = logger.setContext("AppointmentService");
log.debug("Confirming appointment by ID", { appointmentId: id, tenantId: this.tenantId });
@@ -522,6 +593,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");
}
diff --git a/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts b/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts
index 5867054..9a7bbd7 100644
--- a/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts
+++ b/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts
@@ -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(),
@@ -316,6 +317,15 @@ export const POST: RequestHandler = async ({ request, params }) => {
);
// Send appropriate email based on whether confirmation is required
+ if (requiresConfirmation) {
+ // 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 = {
diff --git a/tenant-migrations/0012_military_puppet_master.sql b/tenant-migrations/0012_military_puppet_master.sql
new file mode 100644
index 0000000..c40eb18
--- /dev/null
+++ b/tenant-migrations/0012_military_puppet_master.sql
@@ -0,0 +1 @@
+ALTER TYPE "public"."notification_type" ADD VALUE 'APPOINTMENT_REQUESTED';
\ No newline at end of file
diff --git a/tenant-migrations/meta/0012_snapshot.json b/tenant-migrations/meta/0012_snapshot.json
new file mode 100644
index 0000000..762c014
--- /dev/null
+++ b/tenant-migrations/meta/0012_snapshot.json
@@ -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": {}
+ }
+}
diff --git a/tenant-migrations/meta/_journal.json b/tenant-migrations/meta/_journal.json
index 2dce0c4..f6f3b09 100644
--- a/tenant-migrations/meta/_journal.json
+++ b/tenant-migrations/meta/_journal.json
@@ -85,6 +85,13 @@
"when": 1768926072442,
"tag": "0011_kind_starbolt",
"breakpoints": true
+ },
+ {
+ "idx": 12,
+ "version": "7",
+ "when": 1769078158060,
+ "tag": "0012_military_puppet_master",
+ "breakpoints": true
}
]
}