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:
Hendrik
2026-01-13 18:05:32 +01:00
committed by GitHub
co-authored by Copilot
parent 8c0343b1ec
commit a6707ef081
17 changed files with 3220 additions and 85 deletions
+23 -77
View File
@@ -150,9 +150,7 @@
"tenant_short_name_unique": {
"name": "tenant_short_name_unique",
"nullsNotDistinct": false,
"columns": [
"short_name"
]
"columns": ["short_name"]
}
},
"policies": {},
@@ -238,12 +236,8 @@
"name": "tenant_config_tenant_id_tenant_id_fk",
"tableFrom": "tenant_config",
"tableTo": "tenant",
"columnsFrom": [
"tenant_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -380,12 +374,8 @@
"name": "user_tenant_id_tenant_id_fk",
"tableFrom": "user",
"tableTo": "tenant",
"columnsFrom": [
"tenant_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -395,9 +385,7 @@
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
@@ -552,12 +540,8 @@
"name": "user_invite_tenant_id_tenant_id_fk",
"tableFrom": "user_invite",
"tableTo": "tenant",
"columnsFrom": [
"tenant_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -565,12 +549,8 @@
"name": "user_invite_invited_by_user_id_fk",
"tableFrom": "user_invite",
"tableTo": "user",
"columnsFrom": [
"invited_by"
],
"columnsTo": [
"id"
],
"columnsFrom": ["invited_by"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -578,12 +558,8 @@
"name": "user_invite_created_user_id_user_id_fk",
"tableFrom": "user_invite",
"tableTo": "user",
"columnsFrom": [
"created_user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["created_user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -593,9 +569,7 @@
"user_invite_invite_code_unique": {
"name": "user_invite_invite_code_unique",
"nullsNotDistinct": false,
"columns": [
"invite_code"
]
"columns": ["invite_code"]
}
},
"policies": {},
@@ -680,12 +654,8 @@
"name": "user_passkey_user_id_user_id_fk",
"tableFrom": "user_passkey",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -808,12 +778,8 @@
"name": "user_session_user_id_user_id_fk",
"tableFrom": "user_session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -823,9 +789,7 @@
"user_session_session_token_unique": {
"name": "user_session_session_token_unique",
"nullsNotDistinct": false,
"columns": [
"session_token"
]
"columns": ["session_token"]
}
},
"policies": {},
@@ -837,40 +801,22 @@
"public.config_type": {
"name": "config_type",
"schema": "public",
"values": [
"BOOLEAN",
"NUMBER",
"STRING"
]
"values": ["BOOLEAN", "NUMBER", "STRING"]
},
"public.confirmation_state": {
"name": "confirmation_state",
"schema": "public",
"values": [
"INVITED",
"CONFIRMED",
"ACCESS_GRANTED"
]
"values": ["INVITED", "CONFIRMED", "ACCESS_GRANTED"]
},
"public.setup_state": {
"name": "setup_state",
"schema": "public",
"values": [
"SETTINGS",
"AGENTS",
"CHANNELS",
"STAFF",
"READY"
]
"values": ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"]
},
"public.user_role": {
"name": "user_role",
"schema": "public",
"values": [
"GLOBAL_ADMIN",
"TENANT_ADMIN",
"STAFF"
]
"values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]
}
},
"schemas": {},
@@ -883,4 +829,4 @@
"schemas": {},
"tables": {}
}
}
}
+1 -1
View File
@@ -66,4 +66,4 @@
"breakpoints": true
}
]
}
}
-1
View File
@@ -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";
+38
View File
@@ -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;
}
}
}
@@ -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();
@@ -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();
+1 -6
View File
@@ -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();
}
};
+15
View File
@@ -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;
+6
View File
@@ -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";
+825
View File
@@ -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": {}
}
}
+839
View File
@@ -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": {}
}
}
+14
View File
@@ -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
}
]
}