mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
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>
This commit is contained in:
@@ -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": {},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof notificationCreationSchema>;
|
||||
|
||||
export interface SelectNotification {
|
||||
id: string;
|
||||
staffId: string;
|
||||
type: NotificationType;
|
||||
metaData: { [key: string]: string } | null;
|
||||
isRead: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class NotificationService {
|
||||
#db: Awaited<ReturnType<typeof getTenantDb>> | 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<string[]> {
|
||||
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<SelectNotification[]> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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();
|
||||
|
||||
+2
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user