From a6707ef0810f61384bbc5bb6a6a1f9caaf8bf07a Mon Sep 17 00:00:00 2001 From: Hendrik Date: Tue, 13 Jan 2026 18:05:32 +0100 Subject: [PATCH] 151 bare minimum notification system (#161) * 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 * No longer store texts in notifications * Single source of truth for notification types * Formatting * Fixed linting errors --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- migrations/meta/0008_snapshot.json | 100 +-- migrations/meta/_journal.json | 2 +- src/lib/emails/AppointmentRequest.svelte | 1 - src/lib/server/db/tenant-schema.ts | 38 + .../__tests__/notification-service.test.ts | 506 +++++++++++ .../server/services/notification-service.ts | 307 +++++++ .../auth/book-appointment-login-form.svelte | 2 + .../book-appointment-register-form.svelte | 2 + src/routes/(pages)/login/login-form.svelte | 7 +- .../api/tenants/[id]/notifications/+server.ts | 266 ++++++ .../notifications/[notificationId]/+server.ts | 254 ++++++ .../[id]/notifications/unread/+server.ts | 121 +++ tenant-migrations/0008_neat_swarm.sql | 15 + tenant-migrations/0009_far_korg.sql | 6 + tenant-migrations/meta/0008_snapshot.json | 825 +++++++++++++++++ tenant-migrations/meta/0009_snapshot.json | 839 ++++++++++++++++++ tenant-migrations/meta/_journal.json | 14 + 17 files changed, 3220 insertions(+), 85 deletions(-) create mode 100644 src/lib/server/services/__tests__/notification-service.test.ts create mode 100644 src/lib/server/services/notification-service.ts create mode 100644 src/routes/api/tenants/[id]/notifications/+server.ts create mode 100644 src/routes/api/tenants/[id]/notifications/[notificationId]/+server.ts create mode 100644 src/routes/api/tenants/[id]/notifications/unread/+server.ts create mode 100644 tenant-migrations/0008_neat_swarm.sql create mode 100644 tenant-migrations/0009_far_korg.sql create mode 100644 tenant-migrations/meta/0008_snapshot.json create mode 100644 tenant-migrations/meta/0009_snapshot.json diff --git a/migrations/meta/0008_snapshot.json b/migrations/meta/0008_snapshot.json index 5798bdf..23db82f 100644 --- a/migrations/meta/0008_snapshot.json +++ b/migrations/meta/0008_snapshot.json @@ -150,9 +150,7 @@ "tenant_short_name_unique": { "name": "tenant_short_name_unique", "nullsNotDistinct": false, - "columns": [ - "short_name" - ] + "columns": ["short_name"] } }, "policies": {}, @@ -238,12 +236,8 @@ "name": "tenant_config_tenant_id_tenant_id_fk", "tableFrom": "tenant_config", "tableTo": "tenant", - "columnsFrom": [ - "tenant_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -380,12 +374,8 @@ "name": "user_tenant_id_tenant_id_fk", "tableFrom": "user", "tableTo": "tenant", - "columnsFrom": [ - "tenant_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -395,9 +385,7 @@ "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] } }, "policies": {}, @@ -552,12 +540,8 @@ "name": "user_invite_tenant_id_tenant_id_fk", "tableFrom": "user_invite", "tableTo": "tenant", - "columnsFrom": [ - "tenant_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -565,12 +549,8 @@ "name": "user_invite_invited_by_user_id_fk", "tableFrom": "user_invite", "tableTo": "user", - "columnsFrom": [ - "invited_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -578,12 +558,8 @@ "name": "user_invite_created_user_id_user_id_fk", "tableFrom": "user_invite", "tableTo": "user", - "columnsFrom": [ - "created_user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["created_user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -593,9 +569,7 @@ "user_invite_invite_code_unique": { "name": "user_invite_invite_code_unique", "nullsNotDistinct": false, - "columns": [ - "invite_code" - ] + "columns": ["invite_code"] } }, "policies": {}, @@ -680,12 +654,8 @@ "name": "user_passkey_user_id_user_id_fk", "tableFrom": "user_passkey", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -808,12 +778,8 @@ "name": "user_session_user_id_user_id_fk", "tableFrom": "user_session", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -823,9 +789,7 @@ "user_session_session_token_unique": { "name": "user_session_session_token_unique", "nullsNotDistinct": false, - "columns": [ - "session_token" - ] + "columns": ["session_token"] } }, "policies": {}, @@ -837,40 +801,22 @@ "public.config_type": { "name": "config_type", "schema": "public", - "values": [ - "BOOLEAN", - "NUMBER", - "STRING" - ] + "values": ["BOOLEAN", "NUMBER", "STRING"] }, "public.confirmation_state": { "name": "confirmation_state", "schema": "public", - "values": [ - "INVITED", - "CONFIRMED", - "ACCESS_GRANTED" - ] + "values": ["INVITED", "CONFIRMED", "ACCESS_GRANTED"] }, "public.setup_state": { "name": "setup_state", "schema": "public", - "values": [ - "SETTINGS", - "AGENTS", - "CHANNELS", - "STAFF", - "READY" - ] + "values": ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"] }, "public.user_role": { "name": "user_role", "schema": "public", - "values": [ - "GLOBAL_ADMIN", - "TENANT_ADMIN", - "STAFF" - ] + "values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] } }, "schemas": {}, @@ -883,4 +829,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 7064b7f..a2729bd 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -66,4 +66,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/lib/emails/AppointmentRequest.svelte b/src/lib/emails/AppointmentRequest.svelte index ab54418..473eea7 100644 --- a/src/lib/emails/AppointmentRequest.svelte +++ b/src/lib/emails/AppointmentRequest.svelte @@ -5,7 +5,6 @@ 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 EmailButton from "./components/EmailButton.svelte"; import EmailHeadline from "./components/EmailHeadline.svelte"; import EmailLayout from "./components/EmailLayout.svelte"; import EmailText from "./components/EmailText.svelte"; diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index cd44d24..ba58b03 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -26,6 +26,11 @@ export const appointmentStatusEnum = pgEnum("appointment_status", [ "NO_SHOW", ]); +export const notificationTypes = ["APPOINTMENT_CONFIRMED", "APPOINTMENT_CANCELED"] as const; +export type NotificationType = (typeof notificationTypes)[number]; + +export const notificationTypeEnum = pgEnum("notification_type", notificationTypes); + /** * Agent table - represents personnel or staff members who can be assigned to channels * Agents are the people who provide services and can be associated with multiple channels @@ -91,6 +96,18 @@ export const slotTemplate = pgTable("slotTemplate", { duration: integer("duration").notNull(), }); +/** + * Channel-Staff junction table - establishes many-to-many relationship. Note that staff is not a reference since they are stored in another database + */ +export const channelStaff = pgTable("channel_staff", { + /** Foreign key to channel */ + channelId: uuid("channel_id") + .notNull() + .references(() => channel.id), + /** Key to staff member */ + staffId: uuid("staff_id").notNull(), +}); + /** * Channel-Agent junction table - establishes many-to-many relationship * Links channels with the agents who can provide services for that channel @@ -211,6 +228,27 @@ export const appointmentKeyShare = pgTable("appointment_key_share", { encryptedKey: text("encrypted_key").notNull(), }); +/** + * Notification table - represents notifications related to appointments and other relevant system events + * Used to inform staff about new appointments, changes, or cancellations + * Stored in tenant-specific database + * @table notification + */ +export const notification = pgTable("notification", { + /** Primary key - unique identifier */ + id: uuid("id").primaryKey().defaultRandom(), + /** Reference to staff */ + staffId: uuid("staff_id").notNull(), + /** Notification type */ + type: notificationTypeEnum("type").notNull().default("APPOINTMENT_CONFIRMED"), + /** Additional metadata (e.g. reference to appointment) */ + metaData: json("meta_data").$type<{ [key: string]: string }>(), + /** Whether the notification was read */ + isRead: boolean("is_read").default(false).notNull(), + /** Timestamp when the notification was created */ + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + /** * TypeScript type exports for use in application code * These types represent the shape of data when queried from the database diff --git a/src/lib/server/services/__tests__/notification-service.test.ts b/src/lib/server/services/__tests__/notification-service.test.ts new file mode 100644 index 0000000..6fb7cb5 --- /dev/null +++ b/src/lib/server/services/__tests__/notification-service.test.ts @@ -0,0 +1,506 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock dependencies before imports +vi.mock("../../db", () => ({ + getTenantDb: vi.fn(), + centralDb: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock("$lib/logger", () => ({ + UniversalLogger: vi.fn(() => ({ + setContext: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + })), + })), + default: { + setContext: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + })), + }, +})); + +// Import after mocking +import { NotificationService, type NotificationCreationRequest } from "../notification-service"; +import { getTenantDb } from "../../db"; +import { ValidationError } from "../../utils/errors"; + +// Mock database operations +const mockDb = { + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + returning: vi.fn(), + })), + })), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(), + orderBy: vi.fn(), + })), + })), + })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn(), + })), + })), +}; + +const mockNotification = { + id: "notification-123", + staffId: "staff-123", + type: "APPOINTMENT_CONFIRMED" as const, + metaData: { appointmentId: "appointment-123" }, + isRead: false, + createdAt: new Date(), +}; + +const mockChannelStaff = [ + { staffId: "staff-123" }, + { staffId: "staff-456" }, + { staffId: "staff-789" }, +]; + +describe("NotificationService", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + }); + + describe("forTenant", () => { + it("should create notification service for tenant", async () => { + const service = await NotificationService.forTenant("tenant-123"); + + expect(service.tenantId).toBe("tenant-123"); + expect(getTenantDb).toHaveBeenCalledWith("tenant-123"); + }); + + it("should handle database connection error", async () => { + vi.mocked(getTenantDb).mockRejectedValue(new Error("DB connection failed")); + + await expect(NotificationService.forTenant("tenant-123")).rejects.toThrow( + "DB connection failed", + ); + }); + }); + + describe("createNotification", () => { + let service: NotificationService; + + beforeEach(async () => { + service = await NotificationService.forTenant("tenant-123"); + }); + + it("should create notifications for all staff members in channel", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue(mockChannelStaff), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const insertChain = { + values: vi.fn(() => ({ + returning: vi + .fn() + .mockResolvedValue([ + { id: "notification-1" }, + { id: "notification-2" }, + { id: "notification-3" }, + ]), + })), + }; + mockDb.insert.mockReturnValue(insertChain); + + const request: NotificationCreationRequest = { + channelId: "550e8400-e29b-41d4-a716-446655440000", + type: "APPOINTMENT_CONFIRMED", + metaData: { appointmentId: "appointment-123" }, + }; + + const result = await service.createNotification(request); + + expect(result).toEqual(["notification-1", "notification-2", "notification-3"]); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.insert).toHaveBeenCalled(); + expect(insertChain.values).toHaveBeenCalledWith([ + { + staffId: "staff-123", + type: request.type, + metaData: request.metaData, + isRead: false, + }, + { + staffId: "staff-456", + type: request.type, + metaData: request.metaData, + isRead: false, + }, + { + staffId: "staff-789", + type: request.type, + metaData: request.metaData, + isRead: false, + }, + ]); + }); + + it("should return empty array when no staff members found", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([]), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const request: NotificationCreationRequest = { + channelId: "550e8400-e29b-41d4-a716-446655440000", + type: "APPOINTMENT_CONFIRMED", + }; + + const result = await service.createNotification(request); + + expect(result).toEqual([]); + expect(mockDb.insert).not.toHaveBeenCalled(); + }); + + it("should handle validation error for invalid channel ID", async () => { + const request = { + channelId: "invalid-uuid", + type: "APPOINTMENT_CONFIRMED", + }; + + await expect(service.createNotification(request as any)).rejects.toThrow(ValidationError); + }); + + it("should handle validation error for missing type", async () => { + const request = { + channelId: "550e8400-e29b-41d4-a716-446655440000", + }; + + await expect(service.createNotification(request as any)).rejects.toThrow(ValidationError); + }); + + it("should handle database error during creation", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue(mockChannelStaff), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const insertChain = { + values: vi.fn(() => ({ + returning: vi.fn().mockRejectedValue(new Error("DB error")), + })), + }; + mockDb.insert.mockReturnValue(insertChain); + + const request: NotificationCreationRequest = { + channelId: "550e8400-e29b-41d4-a716-446655440000", + type: "APPOINTMENT_CONFIRMED", + }; + + await expect(service.createNotification(request)).rejects.toThrow("DB error"); + }); + }); + + describe("getNotificationsForStaff", () => { + let service: NotificationService; + + beforeEach(async () => { + service = await NotificationService.forTenant("tenant-123"); + }); + + it("should return all notifications for staff member", async () => { + const mockNotifications = [ + mockNotification, + { + id: "notification-456", + staffId: "staff-123", + type: "APPOINTMENT_CANCELED" as const, + metaData: { appointmentId: "appointment-456" }, + isRead: true, + createdAt: new Date(), + }, + ]; + + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn().mockResolvedValue(mockNotifications), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const result = await service.getNotificationsForStaff("staff-123"); + + expect(result).toEqual(mockNotifications); + expect(mockDb.select).toHaveBeenCalled(); + }); + + it("should return empty array when no notifications found", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn().mockResolvedValue([]), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const result = await service.getNotificationsForStaff("staff-123"); + + expect(result).toEqual([]); + }); + + it("should handle database error", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn().mockRejectedValue(new Error("DB error")), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + await expect(service.getNotificationsForStaff("staff-123")).rejects.toThrow("DB error"); + }); + }); + + describe("hasUnreadNotifications", () => { + let service: NotificationService; + + beforeEach(async () => { + service = await NotificationService.forTenant("tenant-123"); + }); + + it("should return true when unread notifications exist", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockResolvedValue([{ id: "notification-123" }]), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const result = await service.hasUnreadNotifications("staff-123"); + + expect(result).toBe(true); + }); + + it("should return false when no unread notifications exist", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockResolvedValue([]), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const result = await service.hasUnreadNotifications("staff-123"); + + expect(result).toBe(false); + }); + + it("should handle database error", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockRejectedValue(new Error("DB error")), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + await expect(service.hasUnreadNotifications("staff-123")).rejects.toThrow("DB error"); + }); + }); + + describe("deleteNotification", () => { + let service: NotificationService; + + beforeEach(async () => { + service = await NotificationService.forTenant("tenant-123"); + }); + + it("should delete notification successfully when it belongs to staff member", async () => { + const deleteChain = { + where: vi.fn().mockResolvedValue(undefined), + }; + mockDb.delete.mockReturnValue(deleteChain); + + await service.deleteNotification("notification-123", "staff-123"); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it("should not throw error when notification does not exist or belongs to different staff member", async () => { + const deleteChain = { + where: vi.fn().mockResolvedValue(undefined), + }; + mockDb.delete.mockReturnValue(deleteChain); + + // Should complete without error even if notification doesn't exist + await service.deleteNotification("notification-123", "staff-456"); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it("should handle database error during deletion", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockResolvedValue([mockNotification]), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const deleteChain = { + where: vi.fn().mockRejectedValue(new Error("DB error")), + }; + mockDb.delete.mockReturnValue(deleteChain); + + await expect(service.deleteNotification("notification-123", "staff-123")).rejects.toThrow( + "DB error", + ); + }); + }); + + describe("markAsRead", () => { + let service: NotificationService; + + beforeEach(async () => { + service = await NotificationService.forTenant("tenant-123"); + }); + + it("should mark notification as read successfully when it belongs to staff member", async () => { + const updateChain = { + set: vi.fn(() => ({ + where: vi.fn().mockResolvedValue(undefined), + })), + }; + mockDb.update.mockReturnValue(updateChain); + + await service.markAsRead("notification-123", "staff-123"); + + expect(mockDb.update).toHaveBeenCalled(); + expect(updateChain.set).toHaveBeenCalledWith({ isRead: true }); + }); + + it("should not throw error when notification does not exist or belongs to different staff member", async () => { + const updateChain = { + set: vi.fn(() => ({ + where: vi.fn().mockResolvedValue(undefined), + })), + }; + mockDb.update.mockReturnValue(updateChain); + + // Should complete without error even if notification doesn't exist + await service.markAsRead("notification-123", "staff-456"); + + expect(mockDb.update).toHaveBeenCalled(); + }); + + it("should handle database error during update", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockResolvedValue([mockNotification]), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const updateChain = { + set: vi.fn(() => ({ + where: vi.fn().mockRejectedValue(new Error("DB error")), + })), + }; + mockDb.update.mockReturnValue(updateChain); + + await expect(service.markAsRead("notification-123", "staff-123")).rejects.toThrow("DB error"); + }); + }); + + describe("deleteAllNotifications", () => { + let service: NotificationService; + + beforeEach(async () => { + service = await NotificationService.forTenant("tenant-123"); + }); + + it("should delete all notifications for staff member", async () => { + const deleteChain = { + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([{ id: "1" }, { id: "2" }, { id: "3" }]), + })), + }; + mockDb.delete.mockReturnValue(deleteChain); + + const result = await service.deleteAllNotifications("staff-123", false); + + expect(result).toBe(3); + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it("should delete only read notifications when readOnly is true", async () => { + const deleteChain = { + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([{ id: "1" }, { id: "2" }]), + })), + }; + mockDb.delete.mockReturnValue(deleteChain); + + const result = await service.deleteAllNotifications("staff-123", true); + + expect(result).toBe(2); + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it("should return 0 when no notifications deleted", async () => { + const deleteChain = { + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([]), + })), + }; + mockDb.delete.mockReturnValue(deleteChain); + + const result = await service.deleteAllNotifications("staff-123", false); + + expect(result).toBe(0); + }); + + it("should handle database error during deletion", async () => { + const deleteChain = { + where: vi.fn(() => ({ + returning: vi.fn().mockRejectedValue(new Error("DB error")), + })), + }; + mockDb.delete.mockReturnValue(deleteChain); + + await expect(service.deleteAllNotifications("staff-123", false)).rejects.toThrow("DB error"); + }); + }); +}); diff --git a/src/lib/server/services/notification-service.ts b/src/lib/server/services/notification-service.ts new file mode 100644 index 0000000..02c1901 --- /dev/null +++ b/src/lib/server/services/notification-service.ts @@ -0,0 +1,307 @@ +import { getTenantDb } from "../db"; +import { + notification, + channelStaff, + notificationTypes, + type NotificationType, +} from "../db/tenant-schema"; +import { eq, and, desc } from "drizzle-orm"; +import logger from "$lib/logger"; +import { z } from "zod"; +import { ValidationError, NotFoundError } from "../utils/errors"; + +const notificationCreationSchema = z.object({ + channelId: z.uuid({ message: "Invalid UUID format" }), + type: z.enum(notificationTypes), + metaData: z.record(z.string(), z.any()).optional(), +}); + +export type NotificationCreationRequest = z.infer; + +export interface SelectNotification { + id: string; + staffId: string; + type: NotificationType; + metaData: { [key: string]: string } | null; + isRead: boolean; + createdAt: Date; +} + +export class NotificationService { + #db: Awaited> | null = null; + + private constructor(public readonly tenantId: string) {} + + /** + * Create a notification service for a specific tenant + * @param tenantId The ID of the tenant + * @returns new NotificationService instance + */ + static async forTenant(tenantId: string) { + const log = logger.setContext("NotificationService"); + log.debug("Creating notification service for tenant", { tenantId }); + + try { + const service = new NotificationService(tenantId); + service.#db = await getTenantDb(tenantId); + + log.debug("Notification service created successfully", { tenantId }); + return service; + } catch (error) { + log.error("Failed to create notification service", { tenantId, error: String(error) }); + throw error; + } + } + + /** + * Create a new notification for all staff members in a channel + * @param request Notification creation request data + * @returns Array of created notification IDs + */ + async createNotification(request: NotificationCreationRequest): Promise { + const log = logger.setContext("NotificationService.createNotification"); + log.debug("Creating notification for channel", { channelId: request.channelId }); + + if (!this.#db) { + log.error("Database connection not initialized"); + throw new Error("Database connection not initialized"); + } + + // Validate request + try { + notificationCreationSchema.parse(request); + } catch (error) { + log.warn("Invalid notification creation request", { error }); + throw new ValidationError("Invalid notification data"); + } + + try { + // Get all staff members for this channel + const staffMembers = await this.#db + .select({ staffId: channelStaff.staffId }) + .from(channelStaff) + .where(eq(channelStaff.channelId, request.channelId)); + + if (staffMembers.length === 0) { + log.warn("No staff members found for channel", { channelId: request.channelId }); + return []; + } + + log.debug("Found staff members for channel", { + channelId: request.channelId, + count: staffMembers.length, + }); + + // Create notifications for all staff members + const notificationsToCreate = staffMembers.map((staff) => ({ + staffId: staff.staffId, + type: request.type, + metaData: request.metaData, + isRead: false, + })); + + const createdNotifications = await this.#db + .insert(notification) + .values(notificationsToCreate) + .returning({ id: notification.id }); + + log.info("Created notifications for channel", { + channelId: request.channelId, + count: createdNotifications.length, + }); + + return createdNotifications.map((n) => n.id); + } catch (error) { + log.error("Failed to create notification", { + channelId: request.channelId, + error: String(error), + }); + throw error; + } + } + + /** + * Get all notifications for a staff member + * @param staffId The ID of the staff member + * @returns Array of notifications + */ + async getNotificationsForStaff(staffId: string): Promise { + const log = logger.setContext("NotificationService.getNotificationsForStaff"); + log.debug("Getting notifications for staff", { staffId }); + + if (!this.#db) { + log.error("Database connection not initialized"); + throw new Error("Database connection not initialized"); + } + + try { + const notifications = await this.#db + .select() + .from(notification) + .where(eq(notification.staffId, staffId)) + .orderBy(desc(notification.createdAt)); + + log.debug("Retrieved notifications for staff", { + staffId, + count: notifications.length, + }); + + return notifications; + } catch (error) { + log.error("Failed to get notifications for staff", { + staffId, + error: String(error), + }); + throw error; + } + } + + /** + * Check if a staff member has unread notifications + * @param staffId The ID of the staff member + * @returns True if there are unread notifications + */ + async hasUnreadNotifications(staffId: string): Promise { + const log = logger.setContext("NotificationService.hasUnreadNotifications"); + log.debug("Checking for unread notifications", { staffId }); + + if (!this.#db) { + log.error("Database connection not initialized"); + throw new Error("Database connection not initialized"); + } + + try { + const unreadNotifications = await this.#db + .select({ id: notification.id }) + .from(notification) + .where(and(eq(notification.staffId, staffId), eq(notification.isRead, false))) + .limit(1); + + const hasUnread = unreadNotifications.length > 0; + + log.debug("Checked unread notifications", { + staffId, + hasUnread, + }); + + return hasUnread; + } catch (error) { + log.error("Failed to check unread notifications", { + staffId, + error: String(error), + }); + throw error; + } + } + + /** + * Delete a specific notification + * @param notificationId The ID of the notification to delete + * @param staffId The ID of the staff member (ownership check) + * @throws NotFoundError if notification not found + */ + async deleteNotification(notificationId: string, staffId: string): Promise { + const log = logger.setContext("NotificationService.deleteNotification"); + log.debug("Deleting notification", { notificationId, staffId }); + + if (!this.#db) { + log.error("Database connection not initialized"); + throw new Error("Database connection not initialized"); + } + + try { + await this.#db + .delete(notification) + .where(and(eq(notification.id, notificationId), eq(notification.staffId, staffId))); + + log.info("Deleted notification", { notificationId, staffId }); + } catch (error) { + if (error instanceof NotFoundError) { + throw error; + } + log.error("Failed to delete notification", { + notificationId, + staffId, + error: String(error), + }); + throw error; + } + } + + /** + * Mark a notification as read + * @param notificationId The ID of the notification to mark as read + * @param staffId The ID of the staff member (ownership check) + * @throws NotFoundError if notification not found + */ + async markAsRead(notificationId: string, staffId: string): Promise { + const log = logger.setContext("NotificationService.markAsRead"); + log.debug("Marking notification as read", { notificationId, staffId }); + + if (!this.#db) { + log.error("Database connection not initialized"); + throw new Error("Database connection not initialized"); + } + + try { + await this.#db + .update(notification) + .set({ isRead: true }) + .where(and(eq(notification.id, notificationId), eq(notification.staffId, staffId))); + + log.info("Marked notification as read", { notificationId, staffId }); + } catch (error) { + if (error instanceof NotFoundError) { + throw error; + } + log.error("Failed to mark notification as read", { + notificationId, + staffId, + error: String(error), + }); + throw error; + } + } + + /** + * Delete all notifications for a staff member + * @param staffId The ID of the staff member + * @param readOnly If true, only delete read notifications + * @returns Number of deleted notifications + */ + async deleteAllNotifications(staffId: string, readOnly: boolean = false): Promise { + const log = logger.setContext("NotificationService.deleteAllNotifications"); + log.debug("Deleting all notifications for staff", { staffId, readOnly }); + + if (!this.#db) { + log.error("Database connection not initialized"); + throw new Error("Database connection not initialized"); + } + + try { + const whereCondition = readOnly + ? and(eq(notification.staffId, staffId), eq(notification.isRead, true)) + : eq(notification.staffId, staffId); + + const deleted = await this.#db + .delete(notification) + .where(whereCondition) + .returning({ id: notification.id }); + + log.info("Deleted notifications for staff", { + staffId, + readOnly, + count: deleted.length, + }); + + return deleted.length; + } catch (error) { + log.error("Failed to delete notifications", { + staffId, + readOnly, + error: String(error), + }); + throw error; + } + } +} diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte index 4108bfe..54ec060 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte @@ -22,6 +22,7 @@ let isThrottled = $derived.by(() => { // Re-evaluate whenever timeCounter changes + // eslint-disable-next-line @typescript-eslint/no-unused-expressions timeCounter; // Read for reactivity if (!appointment.data?.email || !throttleState.throttleUntil) return false; return throttleState.throttleUntil > Date.now(); @@ -29,6 +30,7 @@ let remainingSeconds = $derived.by(() => { // Re-evaluate whenever timeCounter changes + // eslint-disable-next-line @typescript-eslint/no-unused-expressions timeCounter; // Read for reactivity if (!throttleState.throttleUntil) return 0; const remaining = throttleState.throttleUntil - Date.now(); diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte index a461b73..36da129 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte @@ -22,6 +22,7 @@ let isThrottled = $derived.by(() => { // Re-evaluate whenever timeCounter changes + // eslint-disable-next-line @typescript-eslint/no-unused-expressions timeCounter; // Read for reactivity if (!appointment.data?.email || !throttleState.throttleUntil) return false; return throttleState.throttleUntil > Date.now(); @@ -29,6 +30,7 @@ let remainingSeconds = $derived.by(() => { // Re-evaluate whenever timeCounter changes + // eslint-disable-next-line @typescript-eslint/no-unused-expressions timeCounter; // Read for reactivity if (!throttleState.throttleUntil) return 0; const remaining = throttleState.throttleUntil - Date.now(); diff --git a/src/routes/(pages)/login/login-form.svelte b/src/routes/(pages)/login/login-form.svelte index 3641c10..1266307 100644 --- a/src/routes/(pages)/login/login-form.svelte +++ b/src/routes/(pages)/login/login-form.svelte @@ -15,12 +15,7 @@ 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, - getPRFOutputAfterRegistration, - } from "$lib/utils/passkey"; + 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"; diff --git a/src/routes/api/tenants/[id]/notifications/+server.ts b/src/routes/api/tenants/[id]/notifications/+server.ts new file mode 100644 index 0000000..44d087a --- /dev/null +++ b/src/routes/api/tenants/[id]/notifications/+server.ts @@ -0,0 +1,266 @@ +import { json } from "@sveltejs/kit"; +import { NotificationService } from "$lib/server/services/notification-service"; +import { ValidationError, logError, BackendError, InternalError } 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"; +import { ERRORS } from "$lib/errors"; + +// Register OpenAPI documentation for GET +registerOpenAPIRoute("/tenants/{id}/notifications", "GET", { + summary: "List all notifications for current staff member", + description: + "Retrieves all notifications for the authenticated staff member. Only accessible by staff and tenant admins.", + tags: ["Notifications"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + ], + responses: { + "200": { + description: "Notifications retrieved successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + notifications: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string", format: "uuid", description: "Notification ID" }, + staffId: { type: "string", format: "uuid", description: "Staff member ID" }, + type: { + type: "string", + enum: ["APPOINTMENT_CONFIRMED", "APPOINTMENT_CANCELED"], + description: "Notification type", + }, + metaData: { + type: "object", + description: "Additional metadata (e.g. appointmentId)", + }, + isRead: { type: "boolean", description: "Whether notification has been read" }, + createdAt: { + type: "string", + format: "date-time", + description: "Creation timestamp", + }, + }, + required: ["id", "staffId", "type", "isRead", "createdAt"], + }, + }, + }, + required: ["notifications"], + }, + }, + }, + }, + "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: "Tenant not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, +}); + +// Register OpenAPI documentation for DELETE +registerOpenAPIRoute("/tenants/{id}/notifications", "DELETE", { + summary: "Delete all notifications for current staff member", + description: + "Deletes all notifications for the authenticated staff member. Optionally delete only read notifications. Only accessible by staff and tenant admins.", + tags: ["Notifications"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + { + name: "readOnly", + in: "query", + required: false, + schema: { type: "boolean" }, + description: "If true, only delete read notifications", + }, + ], + responses: { + "200": { + description: "Notifications deleted successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + deletedCount: { type: "number", description: "Number of deleted notifications" }, + }, + required: ["message", "deletedCount"], + }, + }, + }, + }, + "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: "Tenant 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 GET: RequestHandler = async ({ params, locals }) => { + const log = logger.setContext("API"); + + try { + const tenantId = params.id; + + // Check if user is authenticated + if (!tenantId) { + throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID); + } + + checkPermission(locals, tenantId); + + if (!locals.user?.id) { + throw new ValidationError("User ID not found"); + } + + log.debug("Getting all notifications", { + tenantId, + staffId: locals.user.id, + }); + + const notificationService = await NotificationService.forTenant(tenantId); + const notifications = await notificationService.getNotificationsForStaff(locals.user.id); + + log.debug("Notifications retrieved successfully", { + tenantId, + staffId: locals.user.id, + count: notifications.length, + }); + + return json({ + notifications, + }); + } catch (error) { + logError(log)("Error getting notifications:", error, locals.user?.id, params.id); + + if (error instanceof BackendError) { + return error.toJson(); + } + + return new InternalError().toJson(); + } +}; + +export const DELETE: RequestHandler = async ({ params, url, locals }) => { + const log = logger.setContext("API"); + + try { + const tenantId = params.id; + + // Check if user is authenticated + if (!tenantId) { + throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID); + } + + checkPermission(locals, tenantId); + + if (!locals.user?.id) { + throw new ValidationError("User ID not found"); + } + + const readOnly = url.searchParams.get("readOnly") === "true"; + + log.debug("Deleting all notifications", { + tenantId, + staffId: locals.user.id, + readOnly, + }); + + const notificationService = await NotificationService.forTenant(tenantId); + const deletedCount = await notificationService.deleteAllNotifications(locals.user.id, readOnly); + + log.info("Notifications deleted successfully", { + tenantId, + staffId: locals.user.id, + deletedCount, + readOnly, + }); + + return json({ + message: "Notifications deleted successfully", + deletedCount, + }); + } catch (error) { + logError(log)("Error deleting notifications:", error, locals.user?.id, params.id); + + if (error instanceof BackendError) { + return error.toJson(); + } + + return new InternalError().toJson(); + } +}; diff --git a/src/routes/api/tenants/[id]/notifications/[notificationId]/+server.ts b/src/routes/api/tenants/[id]/notifications/[notificationId]/+server.ts new file mode 100644 index 0000000..93678e1 --- /dev/null +++ b/src/routes/api/tenants/[id]/notifications/[notificationId]/+server.ts @@ -0,0 +1,254 @@ +import { json } from "@sveltejs/kit"; +import { NotificationService } from "$lib/server/services/notification-service"; +import { ValidationError, logError, BackendError, InternalError } 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"; +import { ERRORS } from "$lib/errors"; + +// Register OpenAPI documentation for DELETE +registerOpenAPIRoute("/tenants/{id}/notifications/{notificationId}", "DELETE", { + summary: "Delete a specific notification", + description: + "Deletes a specific notification belonging to the authenticated staff member. Only accessible by staff and tenant admins.", + tags: ["Notifications"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + { + name: "notificationId", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Notification ID", + }, + ], + responses: { + "200": { + description: "Notification deleted successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + }, + required: ["message"], + }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Insufficient permissions or notification belongs to another user", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Notification or tenant not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, +}); + +// Register OpenAPI documentation for PUT +registerOpenAPIRoute("/tenants/{id}/notifications/{notificationId}", "PUT", { + summary: "Mark a notification as read", + description: + "Marks a specific notification as read for the authenticated staff member. Only accessible by staff and tenant admins.", + tags: ["Notifications"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + { + name: "notificationId", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Notification ID", + }, + ], + responses: { + "200": { + description: "Notification marked as read successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + }, + required: ["message"], + }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Insufficient permissions or notification belongs to another user", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Notification or tenant 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, locals }) => { + const log = logger.setContext("API"); + + try { + const tenantId = params.id; + const notificationId = params.notificationId; + + // Check if user is authenticated + if (!tenantId) { + throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID); + } + + if (!notificationId) { + throw new ValidationError("Notification ID is required"); + } + + checkPermission(locals, tenantId); + + if (!locals.user?.id) { + throw new ValidationError("User ID not found"); + } + + log.debug("Deleting notification", { + tenantId, + staffId: locals.user.id, + notificationId, + }); + + const notificationService = await NotificationService.forTenant(tenantId); + await notificationService.deleteNotification(notificationId, locals.user.id); + + log.info("Notification deleted successfully", { + tenantId, + staffId: locals.user.id, + notificationId, + }); + + return json({ + message: "Notification deleted successfully", + }); + } catch (error) { + logError(log)("Error deleting notification:", error, locals.user?.id, params.id); + + if (error instanceof BackendError) { + return error.toJson(); + } + + return new InternalError().toJson(); + } +}; + +export const PUT: RequestHandler = async ({ params, locals }) => { + const log = logger.setContext("API"); + + try { + const tenantId = params.id; + const notificationId = params.notificationId; + + // Check if user is authenticated + if (!tenantId) { + throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID); + } + + if (!notificationId) { + throw new ValidationError("Notification ID is required"); + } + + checkPermission(locals, tenantId); + + if (!locals.user?.id) { + throw new ValidationError("User ID not found"); + } + + log.debug("Marking notification as read", { + tenantId, + staffId: locals.user.id, + notificationId, + }); + + const notificationService = await NotificationService.forTenant(tenantId); + await notificationService.markAsRead(notificationId, locals.user.id); + + log.info("Notification marked as read successfully", { + tenantId, + staffId: locals.user.id, + notificationId, + }); + + return json({ + message: "Notification marked as read successfully", + }); + } catch (error) { + logError(log)("Error marking notification as read:", error, locals.user?.id, params.id); + + if (error instanceof BackendError) { + return error.toJson(); + } + + return new InternalError().toJson(); + } +}; diff --git a/src/routes/api/tenants/[id]/notifications/unread/+server.ts b/src/routes/api/tenants/[id]/notifications/unread/+server.ts new file mode 100644 index 0000000..a697b30 --- /dev/null +++ b/src/routes/api/tenants/[id]/notifications/unread/+server.ts @@ -0,0 +1,121 @@ +import { json } from "@sveltejs/kit"; +import { NotificationService } from "$lib/server/services/notification-service"; +import { ValidationError, logError, BackendError, InternalError } 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"; +import { ERRORS } from "$lib/errors"; + +// Register OpenAPI documentation for GET +registerOpenAPIRoute("/tenants/{id}/notifications/unread", "GET", { + summary: "Check if staff member has unread notifications", + description: + "Checks whether the authenticated staff member has any unread notifications. Only accessible by staff and tenant admins.", + tags: ["Notifications"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + ], + responses: { + "200": { + description: "Unread status retrieved successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + hasUnread: { + type: "boolean", + description: "Whether the staff member has unread notifications", + }, + }, + required: ["hasUnread"], + }, + }, + }, + }, + "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: "Tenant 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 GET: RequestHandler = async ({ params, locals }) => { + const log = logger.setContext("API"); + + try { + const tenantId = params.id; + + // Check if user is authenticated + if (!tenantId) { + throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID); + } + + checkPermission(locals, tenantId); + + if (!locals.user?.id) { + throw new ValidationError("User ID not found"); + } + + log.debug("Checking for unread notifications", { + tenantId, + staffId: locals.user.id, + }); + + const notificationService = await NotificationService.forTenant(tenantId); + const hasUnread = await notificationService.hasUnreadNotifications(locals.user.id); + + log.debug("Unread notification status retrieved", { + tenantId, + staffId: locals.user.id, + hasUnread, + }); + + return json({ + hasUnread, + }); + } catch (error) { + logError(log)("Error checking unread notifications:", error, locals.user?.id, params.id); + + if (error instanceof BackendError) { + return error.toJson(); + } + + return new InternalError().toJson(); + } +}; diff --git a/tenant-migrations/0008_neat_swarm.sql b/tenant-migrations/0008_neat_swarm.sql new file mode 100644 index 0000000..75aa20e --- /dev/null +++ b/tenant-migrations/0008_neat_swarm.sql @@ -0,0 +1,15 @@ +CREATE TABLE "channel_staff" ( + "channel_id" uuid NOT NULL, + "staff_id" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "notification" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "staff_id" uuid NOT NULL, + "title" json NOT NULL, + "description" json NOT NULL, + "is_read" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "channel_staff" ADD CONSTRAINT "channel_staff_channel_id_channel_id_fk" FOREIGN KEY ("channel_id") REFERENCES "public"."channel"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/tenant-migrations/0009_far_korg.sql b/tenant-migrations/0009_far_korg.sql new file mode 100644 index 0000000..e072f54 --- /dev/null +++ b/tenant-migrations/0009_far_korg.sql @@ -0,0 +1,6 @@ +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"; \ No newline at end of file diff --git a/tenant-migrations/meta/0008_snapshot.json b/tenant-migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..2ff4af2 --- /dev/null +++ b/tenant-migrations/meta/0008_snapshot.json @@ -0,0 +1,825 @@ +{ + "id": "842761ad-082a-42e7-8de0-fa8f549d0fc2", + "prevId": "29c30482-bb21-472c-acbc-6b802aaf0614", + "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_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 + }, + "title": { + "name": "title", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "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"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/tenant-migrations/meta/0009_snapshot.json b/tenant-migrations/meta/0009_snapshot.json new file mode 100644 index 0000000..43ce789 --- /dev/null +++ b/tenant-migrations/meta/0009_snapshot.json @@ -0,0 +1,839 @@ +{ + "id": "00e264b4-3606-4141-863d-7d3511d18155", + "prevId": "842761ad-082a-42e7-8de0-fa8f549d0fc2", + "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_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_CANCELED"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/tenant-migrations/meta/_journal.json b/tenant-migrations/meta/_journal.json index 5341886..c64767d 100644 --- a/tenant-migrations/meta/_journal.json +++ b/tenant-migrations/meta/_journal.json @@ -57,6 +57,20 @@ "when": 1763455361426, "tag": "0007_mighty_tinkerer", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1767445537790, + "tag": "0008_neat_swarm", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1768049916820, + "tag": "0009_far_korg", + "breakpoints": true } ] }