From a64afbd29769315eeeeb8a8fb911f3a4a789a821 Mon Sep 17 00:00:00 2001 From: Hendrik Date: Thu, 22 Jan 2026 11:39:12 +0100 Subject: [PATCH] 160 book appointment confirm appointment requests (#179) * Extension of tenant schema * Notification service * Notification endpoints * Format errors * Update src/lib/server/db/tenant-schema.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/lib/server/services/notification-service.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Explicit where clause in delete notification * Update tenant-migrations/0008_neat_swarm.sql Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Sort notifivcations by creation date. Do not explicitly check for existance in mark as read. * Fixed tests * Mail templates appointment cancellation * Appointment cancellation notification texts * Delete appointment by staff * Deletion endpoint for staff * My Appointments endpoint for clients * Delete appointment by client * Copilot review fixes * Fixes after notification changes and updates. * Fixed limiting errors * Rejection email template * Send out request, confirmed notifications, send out confirmed, denied emails. * Fix test * MIgration files --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- project.inlang/messages/de.json | 5 + project.inlang/messages/en.json | 5 + src/lib/emails/AppointmentRejected.svelte | 61 ++ src/lib/server/db/tenant-schema.ts | 6 +- src/lib/server/email/email-service.ts | 45 + .../__tests__/appointment-service.test.ts | 10 + .../server/services/appointment-service.ts | 82 ++ .../appointments/add-to-tunnel/+server.ts | 10 + .../0012_military_puppet_master.sql | 1 + tenant-migrations/meta/0012_snapshot.json | 898 ++++++++++++++++++ tenant-migrations/meta/_journal.json | 7 + 11 files changed, 1129 insertions(+), 1 deletion(-) create mode 100644 src/lib/emails/AppointmentRejected.svelte create mode 100644 tenant-migrations/0012_military_puppet_master.sql create mode 100644 tenant-migrations/meta/0012_snapshot.json 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 } ] }