diff --git a/docs/api/admin-registration.md b/docs/api/admin-registration.md index 3102f34..5e37a07 100644 --- a/docs/api/admin-registration.md +++ b/docs/api/admin-registration.md @@ -21,11 +21,12 @@ Content-Type: application/json ``` **Response (201):** + ```json { - "message": "Admin account created successfully. Please check your email for confirmation.", - "adminId": "01234567-89ab-cdef-0123-456789abcdef", - "email": "admin@example.com" + "message": "Admin account created successfully. Please check your email for confirmation.", + "adminId": "01234567-89ab-cdef-0123-456789abcdef", + "email": "admin@example.com" } ``` @@ -41,9 +42,10 @@ Content-Type: application/json ``` **Response (200):** + ```json { - "message": "Admin account confirmed successfully. You can now log in." + "message": "Admin account confirmed successfully. You can now log in." } ``` @@ -59,39 +61,44 @@ Content-Type: application/json ``` **Response (200):** + ```json { - "message": "Confirmation email resent successfully. Please check your email." + "message": "Confirmation email resent successfully. Please check your email." } ``` ## Error Responses ### 400 Bad Request + ```json { - "error": "Invalid admin data" + "error": "Invalid admin data" } ``` ### 404 Not Found + ```json { - "error": "Invalid or expired confirmation token" + "error": "Invalid or expired confirmation token" } ``` ### 409 Conflict + ```json { - "error": "An admin with this email already exists" + "error": "An admin with this email already exists" } ``` ### 500 Internal Server Error + ```json { - "error": "Internal server error" + "error": "Internal server error" } ``` @@ -100,4 +107,4 @@ Content-Type: application/json - Admin accounts are created as `isActive: false` and `confirmed: false` - Confirmation tokens expire after 10 minutes - Email sending is marked as TODO in the service layer -- After confirmation, admin becomes `isActive: true` and `confirmed: true` \ No newline at end of file +- After confirmation, admin becomes `isActive: true` and `confirmed: true` diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index 4537328..a36ea9c 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -1,11 +1,5 @@ import type { InferSelectModel } from "drizzle-orm"; -import { - pgTable, - uuid, - text, - date, - pgEnum -} from "drizzle-orm/pg-core"; +import { pgTable, uuid, text, date, pgEnum } from "drizzle-orm/pg-core"; /** * Database enums for tenant-specific entities @@ -128,4 +122,4 @@ export type SelectStaff = InferSelectModel; export type SelectChannel = InferSelectModel; /** Appointment record type for database queries */ -export type SelectAppointment = InferSelectModel; \ No newline at end of file +export type SelectAppointment = InferSelectModel; diff --git a/src/lib/server/services/admin-account-service.test.ts b/src/lib/server/services/admin-account-service.test.ts index b9b27b3..689a883 100644 --- a/src/lib/server/services/admin-account-service.test.ts +++ b/src/lib/server/services/admin-account-service.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { ValidationError, NotFoundError } from '../utils/errors'; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NotFoundError } from "../utils/errors"; // Mock the database module -vi.mock('../db', () => ({ +vi.mock("../db", () => ({ centralDb: { insert: vi.fn(), select: vi.fn(), @@ -12,7 +12,7 @@ vi.mock('../db', () => ({ })); // Mock the logger -vi.mock('$lib/logger', () => ({ +vi.mock("$lib/logger", () => ({ default: { setContext: vi.fn().mockReturnValue({ debug: vi.fn(), @@ -23,17 +23,17 @@ vi.mock('$lib/logger', () => ({ })); // Mock uuid generation -vi.mock('uuidv7', () => ({ +vi.mock("uuidv7", () => ({ uuidv7: vi.fn() })); // Mock date-fns -vi.mock('date-fns', () => ({ +vi.mock("date-fns", () => ({ addMinutes: vi.fn() })); // Mock zod -vi.mock('zod/v4', () => ({ +vi.mock("zod/v4", () => ({ default: { object: vi.fn().mockReturnValue({ safeParse: vi.fn().mockReturnValue({ success: true }) @@ -51,7 +51,7 @@ vi.mock('zod/v4', () => ({ } })); -describe('AdminAccountService', () => { +describe("AdminAccountService", () => { let AdminAccountService: any; let mockCentralDb: any; let mockUuidv7: any; @@ -61,21 +61,21 @@ describe('AdminAccountService', () => { vi.clearAllMocks(); // Import the service after mocks are set up - AdminAccountService = (await import('./admin-account-service')).AdminAccountService; - + AdminAccountService = (await import("./admin-account-service")).AdminAccountService; + // Get mocked modules - const dbModule = await vi.importMock('../db'); + const dbModule = await vi.importMock("../db"); mockCentralDb = dbModule.centralDb; - - const uuidModule = await vi.importMock('uuidv7'); + + const uuidModule = await vi.importMock("uuidv7"); mockUuidv7 = uuidModule.uuidv7; - - const dateFnsModule = await vi.importMock('date-fns'); + + const dateFnsModule = await vi.importMock("date-fns"); mockAddMinutes = dateFnsModule.addMinutes; - + // Setup default mock returns - mockUuidv7.mockReturnValue('test-uuid-123'); - const futureDate = new Date('2024-01-01T12:10:00Z'); + mockUuidv7.mockReturnValue("test-uuid-123"); + const futureDate = new Date("2024-01-01T12:10:00Z"); mockAddMinutes.mockReturnValue(futureDate); }); @@ -83,19 +83,19 @@ describe('AdminAccountService', () => { vi.restoreAllMocks(); }); - describe('createAdmin', () => { - it('should create a new admin with valid data', async () => { + describe("createAdmin", () => { + it("should create a new admin with valid data", async () => { const adminData = { - name: 'Test Admin', - email: 'test@example.com' + name: "Test Admin", + email: "test@example.com" }; const mockCreatedAdmin = { - id: 'admin-123', - name: 'Test Admin', - email: 'test@example.com', - token: 'test-uuid-123', - tokenValidUntil: new Date('2024-01-01T12:10:00Z'), + id: "admin-123", + name: "Test Admin", + email: "test@example.com", + token: "test-uuid-123", + tokenValidUntil: new Date("2024-01-01T12:10:00Z"), confirmed: false, isActive: false }; @@ -112,7 +112,7 @@ describe('AdminAccountService', () => { expect(mockCentralDb.insert).toHaveBeenCalled(); expect(mockInsertBuilder.values).toHaveBeenCalledWith({ ...adminData, - token: 'test-uuid-123', + token: "test-uuid-123", tokenValidUntil: expect.any(Date), confirmed: false, isActive: false @@ -121,10 +121,10 @@ describe('AdminAccountService', () => { }); }); - describe('resendConfirmationEmail', () => { - it('should resend confirmation email for existing admin', async () => { - const email = 'test@example.com'; - + describe("resendConfirmationEmail", () => { + it("should resend confirmation email for existing admin", async () => { + const email = "test@example.com"; + const mockUpdateBuilder = { set: vi.fn().mockReturnThis(), execute: vi.fn().mockResolvedValue({ count: 1 }) @@ -136,14 +136,14 @@ describe('AdminAccountService', () => { expect(mockCentralDb.update).toHaveBeenCalled(); expect(mockUpdateBuilder.set).toHaveBeenCalledWith({ - token: 'test-uuid-123', + token: "test-uuid-123", tokenValidUntil: expect.any(Date) }); }); - it('should throw NotFoundError for non-existent admin', async () => { - const email = 'nonexistent@example.com'; - + it("should throw NotFoundError for non-existent admin", async () => { + const email = "nonexistent@example.com"; + const mockUpdateBuilder = { set: vi.fn().mockReturnThis(), execute: vi.fn().mockResolvedValue({ count: 0 }) @@ -151,14 +151,16 @@ describe('AdminAccountService', () => { mockCentralDb.update.mockReturnValue(mockUpdateBuilder); - await expect(AdminAccountService.resendConfirmationEmail(email)).rejects.toThrow(NotFoundError); + await expect(AdminAccountService.resendConfirmationEmail(email)).rejects.toThrow( + NotFoundError + ); }); }); - describe('confirm', () => { - it('should confirm admin with valid token', async () => { - const token = 'valid-token-123'; - + describe("confirm", () => { + it("should confirm admin with valid token", async () => { + const token = "valid-token-123"; + const mockUpdateBuilder = { set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), @@ -176,9 +178,9 @@ describe('AdminAccountService', () => { }); }); - it('should throw NotFoundError for invalid token', async () => { - const token = 'invalid-token'; - + it("should throw NotFoundError for invalid token", async () => { + const token = "invalid-token"; + const mockUpdateBuilder = { set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), @@ -191,13 +193,13 @@ describe('AdminAccountService', () => { }); }); - describe('getAdminByEmail', () => { - it('should return admin for existing email', async () => { - const email = 'test@example.com'; + describe("getAdminByEmail", () => { + it("should return admin for existing email", async () => { + const email = "test@example.com"; const mockAdmin = { - id: 'admin-123', - name: 'Test Admin', - email: 'test@example.com', + id: "admin-123", + name: "Test Admin", + email: "test@example.com", confirmed: true, isActive: true }; @@ -219,8 +221,8 @@ describe('AdminAccountService', () => { expect(result).toEqual(mockAdmin); }); - it('should throw NotFoundError for non-existent email', async () => { - const email = 'nonexistent@example.com'; + it("should throw NotFoundError for non-existent email", async () => { + const email = "nonexistent@example.com"; const mockSelectBuilder = { from: vi.fn().mockReturnThis(), @@ -234,14 +236,14 @@ describe('AdminAccountService', () => { }); }); - describe('updateAdmin', () => { - it('should update admin successfully', async () => { - const adminId = 'admin-123'; - const updateData = { name: 'Updated Admin' }; + describe("updateAdmin", () => { + it("should update admin successfully", async () => { + const adminId = "admin-123"; + const updateData = { name: "Updated Admin" }; const mockUpdatedAdmin = { id: adminId, - name: 'Updated Admin', - email: 'test@example.com', + name: "Updated Admin", + email: "test@example.com", updatedAt: new Date() }; @@ -264,13 +266,13 @@ describe('AdminAccountService', () => { }); }); - describe('deleteAdmin', () => { - it('should delete admin and associated passkeys', async () => { - const adminId = 'admin-123'; + describe("deleteAdmin", () => { + it("should delete admin and associated passkeys", async () => { + const adminId = "admin-123"; const mockDeletedAdmin = { id: adminId, - name: 'Deleted Admin', - email: 'deleted@example.com' + name: "Deleted Admin", + email: "deleted@example.com" }; const mockDeleteBuilder = { @@ -287,14 +289,14 @@ describe('AdminAccountService', () => { }); }); - describe('addPasskey', () => { - it('should add passkey for admin', async () => { - const adminId = 'admin-123'; + describe("addPasskey", () => { + it("should add passkey for admin", async () => { + const adminId = "admin-123"; const passkeyData = { - id: 'passkey-123', - publicKey: 'public-key-data', + id: "passkey-123", + publicKey: "public-key-data", counter: 0, - deviceName: 'Test Device' + deviceName: "Test Device" }; const mockCreatedPasskey = { @@ -321,4 +323,4 @@ describe('AdminAccountService', () => { expect(result).toEqual(mockCreatedPasskey); }); }); -}); \ No newline at end of file +}); diff --git a/src/lib/server/services/admin-account-service.ts b/src/lib/server/services/admin-account-service.ts index 659a50b..59d26e3 100644 --- a/src/lib/server/services/admin-account-service.ts +++ b/src/lib/server/services/admin-account-service.ts @@ -6,6 +6,7 @@ import z from "zod/v4"; import { NotFoundError, ValidationError } from "../utils/errors"; import { uuidv7 } from "uuidv7"; import { addMinutes } from "date-fns"; +import logger from "$lib/logger"; export type InsertAdmin = InferInsertModel; export type InsertAdminPasskey = InferInsertModel; @@ -24,16 +25,39 @@ export class AdminAccountService { * Create a new admin user */ static async createAdmin(adminData: AdminCreation) { + const log = logger.setContext("AdminAccountService"); + log.debug("Creating new admin account", { email: adminData.email, name: adminData.name }); + const validated = adminCreationSchema.safeParse(adminData); - if (!validated.success) throw new ValidationError("Invalid admin data"); + if (!validated.success) { + log.warn("Admin creation failed: Invalid data", { + email: adminData.email, + errors: validated.error.errors + }); + throw new ValidationError("Invalid admin data"); + } + adminData.token = uuidv7(); adminData.tokenValidUntil = addMinutes(new Date(), 10); - const result = await centralDb - .insert(centralSchema.admin) - .values({ ...adminData, confirmed: false, isActive: false }) - .returning(); - // TODO: Send confirmation email to admin (Link contains the above token) - return result[0]; + + try { + const result = await centralDb + .insert(centralSchema.admin) + .values({ ...adminData, confirmed: false, isActive: false }) + .returning(); + + log.debug("Admin account created successfully", { + adminId: result[0].id, + email: result[0].email, + tokenValidUntil: result[0].tokenValidUntil + }); + + // TODO: Send confirmation email to admin (Link contains the above token) + return result[0]; + } catch (error) { + log.error("Failed to create admin account", { email: adminData.email, error: String(error) }); + throw error; + } } /** @@ -41,16 +65,30 @@ export class AdminAccountService { * @param email - Email of the admin to confirm */ static async resendConfirmationEmail(email: string): Promise { + const log = logger.setContext("AdminAccountService"); + log.debug("Resending confirmation email", { email }); + const token = uuidv7(); const tokenValidUntil = addMinutes(new Date(), 10); - const result = await centralDb - .update(centralSchema.admin) - .set({ token, tokenValidUntil }) - .execute(); - if (result.count != 1) { - throw new NotFoundError(`Could not resend confirmation mail for unknown admin ${email}`); + + try { + const result = await centralDb + .update(centralSchema.admin) + .set({ token, tokenValidUntil }) + .execute(); + + if (result.count != 1) { + log.warn("Failed to resend confirmation email: Admin not found", { email }); + throw new NotFoundError(`Could not resend confirmation mail for unknown admin ${email}`); + } + + log.debug("Confirmation email resent successfully", { email, tokenValidUntil }); + // TODO: Send confirmation email + } catch (error) { + if (error instanceof NotFoundError) throw error; + log.error("Failed to resend confirmation email", { email, error: String(error) }); + throw error; } - // TODO: Send confirmation email } /** @@ -58,18 +96,38 @@ export class AdminAccountService { * @param linkToken - The token from the link */ static async confirm(linkToken: string): Promise { - const result = await centralDb - .update(centralSchema.admin) - .set({ confirmed: true, isActive: true }) - .where( - and( - eq(centralSchema.admin.token, linkToken), - gt(centralSchema.admin.tokenValidUntil, new Date()) + const log = logger.setContext("AdminAccountService"); + log.debug("Confirming admin account", { token: linkToken.substring(0, 8) + "..." }); + + try { + const result = await centralDb + .update(centralSchema.admin) + .set({ confirmed: true, isActive: true }) + .where( + and( + eq(centralSchema.admin.token, linkToken), + gt(centralSchema.admin.tokenValidUntil, new Date()) + ) ) - ) - .execute(); - if (result.count != 1) { - throw new NotFoundError("Invalid or timed-out token"); + .execute(); + + if (result.count != 1) { + log.warn("Admin confirmation failed: Invalid or expired token", { + token: linkToken.substring(0, 8) + "..." + }); + throw new NotFoundError("Invalid or timed-out token"); + } + + log.debug("Admin account confirmed successfully", { + token: linkToken.substring(0, 8) + "..." + }); + } catch (error) { + if (error instanceof NotFoundError) throw error; + log.error("Failed to confirm admin account", { + token: linkToken.substring(0, 8) + "...", + error: String(error) + }); + throw error; } } @@ -77,26 +135,53 @@ export class AdminAccountService { * Get admin by email */ static async getAdminByEmail(email: string) { - const result = await centralDb - .select() - .from(centralSchema.admin) - .where(eq(centralSchema.admin.email, email)) - .limit(1); - if (!result[0]) { - throw new NotFoundError(`No admin account for ${email}.`); - } + const log = logger.setContext("AdminAccountService"); + log.debug("Getting admin by email", { email }); - return result[0]; + try { + const result = await centralDb + .select() + .from(centralSchema.admin) + .where(eq(centralSchema.admin.email, email)) + .limit(1); + + if (!result[0]) { + log.warn("Admin not found by email", { email }); + throw new NotFoundError(`No admin account for ${email}.`); + } + + log.debug("Admin found by email", { + email, + adminId: result[0].id, + confirmed: result[0].confirmed + }); + return result[0]; + } catch (error) { + if (error instanceof NotFoundError) throw error; + log.error("Failed to get admin by email", { email, error: String(error) }); + throw error; + } } /** * Get all admins */ static async getAllAdmins() { - return await centralDb - .select() - .from(centralSchema.admin) - .orderBy(desc(centralSchema.admin.createdAt)); + const log = logger.setContext("AdminAccountService"); + log.debug("Getting all admins"); + + try { + const result = await centralDb + .select() + .from(centralSchema.admin) + .orderBy(desc(centralSchema.admin.createdAt)); + + log.debug("Retrieved all admins", { count: result.length }); + return result; + } catch (error) { + log.error("Failed to get all admins", { error: String(error) }); + throw error; + } } /** @@ -106,40 +191,73 @@ export class AdminAccountService { adminId: string, updateData: Partial> ) { - const result = await centralDb - .update(centralSchema.admin) - .set({ - ...updateData, - updatedAt: new Date() - }) - .where(eq(centralSchema.admin.id, adminId)) - .returning(); + const log = logger.setContext("AdminAccountService"); + log.debug("Updating admin", { adminId, updateFields: Object.keys(updateData) }); - return result[0] || null; + try { + const result = await centralDb + .update(centralSchema.admin) + .set({ + ...updateData, + updatedAt: new Date() + }) + .where(eq(centralSchema.admin.id, adminId)) + .returning(); + + if (result[0]) { + log.debug("Admin updated successfully", { adminId, updateFields: Object.keys(updateData) }); + } else { + log.warn("Admin update failed: Admin not found", { adminId }); + } + + return result[0] || null; + } catch (error) { + log.error("Failed to update admin", { adminId, error: String(error) }); + throw error; + } } /** * Permanently delete admin and all associated passkeys */ static async deleteAdmin(adminId: string) { - // Delete associated passkeys first - await centralDb - .delete(centralSchema.adminPasskey) - .where(eq(centralSchema.adminPasskey.adminId, adminId)); + const log = logger.setContext("AdminAccountService"); + log.debug("Deleting admin and associated passkeys", { adminId }); - // Delete admin - const result = await centralDb - .delete(centralSchema.admin) - .where(eq(centralSchema.admin.id, adminId)) - .returning(); + try { + // Delete associated passkeys first + const passkeyResult = await centralDb + .delete(centralSchema.adminPasskey) + .where(eq(centralSchema.adminPasskey.adminId, adminId)); - return result[0] || null; + log.debug("Deleted admin passkeys", { adminId, deletedCount: passkeyResult.count || 0 }); + + // Delete admin + const result = await centralDb + .delete(centralSchema.admin) + .where(eq(centralSchema.admin.id, adminId)) + .returning(); + + if (result[0]) { + log.debug("Admin deleted successfully", { adminId, email: result[0].email }); + } else { + log.warn("Admin deletion failed: Admin not found", { adminId }); + } + + return result[0] || null; + } catch (error) { + log.error("Failed to delete admin", { adminId, error: String(error) }); + throw error; + } } /** * Update last login timestamp */ static async updateLastLogin(adminId: string) { + const log = logger.setContext("AdminAccountService"); + log.debug("Updating last login timestamp", { adminId }); + return await this.updateAdmin(adminId, { lastLoginAt: new Date() }); } @@ -150,15 +268,36 @@ export class AdminAccountService { adminId: string, passkeyData: Omit ) { - const result = await centralDb - .insert(centralSchema.adminPasskey) - .values({ - ...passkeyData, - adminId - }) - .returning(); + const log = logger.setContext("AdminAccountService"); + log.debug("Adding passkey for admin", { + adminId, + passkeyId: passkeyData.id, + deviceName: passkeyData.deviceName + }); - return result[0]; + try { + const result = await centralDb + .insert(centralSchema.adminPasskey) + .values({ + ...passkeyData, + adminId + }) + .returning(); + + log.debug("Passkey added successfully", { + adminId, + passkeyId: result[0].id, + deviceName: result[0].deviceName + }); + return result[0]; + } catch (error) { + log.error("Failed to add passkey", { + adminId, + passkeyId: passkeyData.id, + error: String(error) + }); + throw error; + } } /** @@ -207,6 +346,9 @@ export class AdminAccountService { * Update passkey last used timestamp and counter */ static async updatePasskeyUsage(passkeyId: string, newCounter: number) { + const log = logger.setContext("AdminAccountService"); + log.debug("Updating passkey usage", { passkeyId, newCounter }); + return await this.updatePasskey(passkeyId, { counter: newCounter, lastUsedAt: new Date() diff --git a/src/lib/server/services/tenant-admin-service.test.ts b/src/lib/server/services/tenant-admin-service.test.ts index 29b41ab..76db537 100644 --- a/src/lib/server/services/tenant-admin-service.test.ts +++ b/src/lib/server/services/tenant-admin-service.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Mock the database module -vi.mock('../db', () => ({ +vi.mock("../db", () => ({ centralDb: { insert: vi.fn(), select: vi.fn(), @@ -12,20 +12,20 @@ vi.mock('../db', () => ({ })); // Mock TenantConfig -vi.mock('../db/tenant-config', () => ({ +vi.mock("../db/tenant-config", () => ({ TenantConfig: { create: vi.fn() } })); // Mock environment variables -vi.mock('$env/dynamic/private', () => ({ +vi.mock("$env/dynamic/private", () => ({ env: { - DATABASE_URL: 'postgresql://user:pass@localhost:5432/central_db' + DATABASE_URL: "postgresql://user:pass@localhost:5432/central_db" } })); -describe('TenantAdminService', () => { +describe("TenantAdminService", () => { let TenantAdminService: any; let mockCentralDb: any; let mockGetTenantDb: any; @@ -35,14 +35,14 @@ describe('TenantAdminService', () => { vi.clearAllMocks(); // Import the service after mocks are set up - TenantAdminService = (await import('./tenant-admin-service')).TenantAdminService; - + TenantAdminService = (await import("./tenant-admin-service")).TenantAdminService; + // Get mocked modules - const dbModule = await vi.importMock('../db'); + const dbModule = await vi.importMock("../db"); mockCentralDb = dbModule.centralDb; mockGetTenantDb = dbModule.getTenantDb; - - const configModule = await vi.importMock('../db/tenant-config'); + + const configModule = await vi.importMock("../db/tenant-config"); mockTenantConfig = configModule.TenantConfig; }); @@ -50,18 +50,18 @@ describe('TenantAdminService', () => { vi.restoreAllMocks(); }); - describe('createTenant', () => { - it('should create a new tenant with default configuration', async () => { + describe("createTenant", () => { + it("should create a new tenant with default configuration", async () => { const newTenant = { - shortName: 'test-clinic', - longName: 'Test Medical Clinic', - description: 'A test clinic' + shortName: "test-clinic", + longName: "Test Medical Clinic", + description: "A test clinic" }; const mockCreatedTenant = { - id: 'tenant-123', + id: "tenant-123", ...newTenant, - databaseUrl: 'postgresql://user:pass@localhost:5432/test-clinic' + databaseUrl: "postgresql://user:pass@localhost:5432/test-clinic" }; const mockConfig = { @@ -81,17 +81,17 @@ describe('TenantAdminService', () => { expect(mockCentralDb.insert).toHaveBeenCalled(); expect(mockInsertBuilder.values).toHaveBeenCalledWith({ ...newTenant, - databaseUrl: 'postgresql://user:pass@localhost:5432/test-clinic' + databaseUrl: "postgresql://user:pass@localhost:5432/test-clinic" }); - expect(mockTenantConfig.create).toHaveBeenCalledWith('tenant-123'); - expect(mockConfig.setConfig).toHaveBeenCalledWith('brandColor', '#E11E15'); + expect(mockTenantConfig.create).toHaveBeenCalledWith("tenant-123"); + expect(mockConfig.setConfig).toHaveBeenCalledWith("brandColor", "#E11E15"); expect(result).toBeInstanceOf(TenantAdminService); }); }); - describe('getTenantById', () => { - it('should get tenant by ID and initialize configuration', async () => { - const tenantId = 'tenant-123'; + describe("getTenantById", () => { + it("should get tenant by ID and initialize configuration", async () => { + const tenantId = "tenant-123"; const mockConfig = { setConfig: vi.fn(), getConfig: vi.fn() @@ -107,12 +107,12 @@ describe('TenantAdminService', () => { }); }); - describe('update', () => { - it('should update tenant data', async () => { - const tenantId = 'tenant-123'; + describe("update", () => { + it("should update tenant data", async () => { + const tenantId = "tenant-123"; const updateData = { - longName: 'Updated Clinic Name', - description: 'Updated description' + longName: "Updated Clinic Name", + description: "Updated description" }; const mockConfig = { setConfig: vi.fn() }; @@ -133,9 +133,9 @@ describe('TenantAdminService', () => { }); }); - describe('getDb', () => { - it('should return database connection', async () => { - const tenantId = 'tenant-123'; + describe("getDb", () => { + it("should return database connection", async () => { + const tenantId = "tenant-123"; const mockConfig = { setConfig: vi.fn() }; const mockTenantDb = { select: vi.fn(), @@ -153,8 +153,8 @@ describe('TenantAdminService', () => { expect(db).toBe(mockTenantDb); }); - it('should cache database connection', async () => { - const tenantId = 'tenant-123'; + it("should cache database connection", async () => { + const tenantId = "tenant-123"; const mockConfig = { setConfig: vi.fn() }; const mockTenantDb = { select: vi.fn() }; @@ -162,7 +162,7 @@ describe('TenantAdminService', () => { mockGetTenantDb.mockResolvedValue(mockTenantDb); const service = await TenantAdminService.getTenantById(tenantId); - + // First call await service.getDb(); // Second call should use cache @@ -172,12 +172,12 @@ describe('TenantAdminService', () => { }); }); - describe('configuration', () => { - it('should provide access to tenant configuration', async () => { - const tenantId = 'tenant-123'; + describe("configuration", () => { + it("should provide access to tenant configuration", async () => { + const tenantId = "tenant-123"; const mockConfig = { setConfig: vi.fn(), - getConfig: vi.fn().mockReturnValue('test-value') + getConfig: vi.fn().mockReturnValue("test-value") }; mockTenantConfig.create.mockResolvedValue(mockConfig); @@ -188,4 +188,4 @@ describe('TenantAdminService', () => { expect(config).toBe(mockConfig); }); }); -}); \ No newline at end of file +}); diff --git a/src/lib/server/services/tenant-admin-service.ts b/src/lib/server/services/tenant-admin-service.ts index 4fe3e87..c273082 100644 --- a/src/lib/server/services/tenant-admin-service.ts +++ b/src/lib/server/services/tenant-admin-service.ts @@ -4,6 +4,7 @@ import { TenantConfig } from "../db/tenant-config"; import { env } from "$env/dynamic/private"; import { eq } from "drizzle-orm"; +import logger from "$lib/logger"; if (!env.DATABASE_URL) throw new Error("DATABASE_URL is not set"); @@ -14,6 +15,12 @@ export class TenantAdminService { private constructor(public readonly tenantId: string) {} static async createTenant(newTenant: centralSchema.InsertTenant) { + const log = logger.setContext("TenantAdminService"); + log.debug("Creating new tenant", { + shortName: newTenant.shortName, + longName: newTenant.longName + }); + const configuration: Record = { brandColor: "#E11E15", defaultLanguage: "DE", @@ -23,33 +30,83 @@ export class TenantAdminService { requireEmail: true, requirePhone: false }; + const urlParts = env.DATABASE_URL.split("/"); urlParts.pop(); newTenant.databaseUrl = urlParts.join("/") + "/" + newTenant.shortName; - const tenant = await centralDb - .insert(centralSchema.tenant) - .values(newTenant) - .returning({ id: centralSchema.tenant.id }); - const config = await TenantConfig.create(tenant[0].id); - for (const [key, value] of Object.entries(configuration)) { - config.setConfig(key, value); + + try { + const tenant = await centralDb + .insert(centralSchema.tenant) + .values(newTenant) + .returning({ id: centralSchema.tenant.id }); + + log.debug("Tenant created in database", { + tenantId: tenant[0].id, + shortName: newTenant.shortName + }); + + const config = await TenantConfig.create(tenant[0].id); + for (const [key, value] of Object.entries(configuration)) { + config.setConfig(key, value); + } + + log.debug("Tenant configuration initialized", { + tenantId: tenant[0].id, + configCount: Object.keys(configuration).length + }); + + const tenantService = new TenantAdminService(tenant[0].id); + tenantService.#config = config; + + log.debug("Tenant service created successfully", { tenantId: tenant[0].id }); + return tenantService; + } catch (error) { + log.error("Failed to create tenant", { + shortName: newTenant.shortName, + error: String(error) + }); + throw error; } - const tenantService = new TenantAdminService(tenant[0].id); - tenantService.#config = config; - return tenantService; } static async getTenantById(id: string) { - const tenant = new TenantAdminService(id); - tenant.#config = await TenantConfig.create(id); - return tenant; + const log = logger.setContext("TenantAdminService"); + log.debug("Getting tenant by ID", { tenantId: id }); + + try { + const tenant = new TenantAdminService(id); + tenant.#config = await TenantConfig.create(id); + + log.debug("Tenant service loaded successfully", { tenantId: id }); + return tenant; + } catch (error) { + log.error("Failed to get tenant by ID", { tenantId: id, error: String(error) }); + throw error; + } } async update(updateData: Partial>) { - await centralDb - .update(centralSchema.tenant) - .set(updateData) - .where(eq(centralSchema.tenant.id, this.tenantId)); + const log = logger.setContext("TenantAdminService"); + log.debug("Updating tenant", { + tenantId: this.tenantId, + updateFields: Object.keys(updateData) + }); + + try { + await centralDb + .update(centralSchema.tenant) + .set(updateData) + .where(eq(centralSchema.tenant.id, this.tenantId)); + + log.debug("Tenant updated successfully", { + tenantId: this.tenantId, + updateFields: Object.keys(updateData) + }); + } catch (error) { + log.error("Failed to update tenant", { tenantId: this.tenantId, error: String(error) }); + throw error; + } } get configuration() { @@ -60,9 +117,24 @@ export class TenantAdminService { * Get the tenant's database connection (cached) */ async getDb() { + const log = logger.setContext("TenantAdminService"); + if (!this.#db) { - this.#db = await getTenantDb(this.tenantId); + log.debug("Creating new tenant database connection", { tenantId: this.tenantId }); + try { + this.#db = await getTenantDb(this.tenantId); + log.debug("Tenant database connection established", { tenantId: this.tenantId }); + } catch (error) { + log.error("Failed to establish tenant database connection", { + tenantId: this.tenantId, + error: String(error) + }); + throw error; + } + } else { + log.debug("Using cached tenant database connection", { tenantId: this.tenantId }); } + return this.#db; } } diff --git a/src/routes/api/admin/confirm/+server.ts b/src/routes/api/admin/confirm/+server.ts index 167ce19..793f5d4 100644 --- a/src/routes/api/admin/confirm/+server.ts +++ b/src/routes/api/admin/confirm/+server.ts @@ -17,7 +17,11 @@ registerOpenAPIRoute("/admin/confirm", "POST", { schema: { type: "object", properties: { - token: { type: "string", description: "Confirmation token from email", example: "01234567-89ab-cdef-0123-456789abcdef" } + token: { + type: "string", + description: "Confirmation token from email", + example: "01234567-89ab-cdef-0123-456789abcdef" + } }, required: ["token"] } diff --git a/src/routes/api/admin/register/+server.ts b/src/routes/api/admin/register/+server.ts index 0929851..33b5ee7 100644 --- a/src/routes/api/admin/register/+server.ts +++ b/src/routes/api/admin/register/+server.ts @@ -18,7 +18,12 @@ registerOpenAPIRoute("/admin/register", "POST", { type: "object", properties: { name: { type: "string", description: "Admin's full name", example: "Admin Name" }, - email: { type: "string", format: "email", description: "Admin's email address", example: "admin@example.com" } + email: { + type: "string", + format: "email", + description: "Admin's email address", + example: "admin@example.com" + } }, required: ["name", "email"] } @@ -40,7 +45,8 @@ registerOpenAPIRoute("/admin/register", "POST", { required: ["message", "adminId", "email"] }, example: { - message: "Admin account created successfully. Please check your email for confirmation.", + message: + "Admin account created successfully. Please check your email for confirmation.", adminId: "01234567-89ab-cdef-0123-456789abcdef", email: "admin@example.com" } diff --git a/src/routes/api/admin/resend-confirmation/+server.ts b/src/routes/api/admin/resend-confirmation/+server.ts index 43e4344..6407d12 100644 --- a/src/routes/api/admin/resend-confirmation/+server.ts +++ b/src/routes/api/admin/resend-confirmation/+server.ts @@ -17,7 +17,12 @@ registerOpenAPIRoute("/admin/resend-confirmation", "POST", { schema: { type: "object", properties: { - email: { type: "string", format: "email", description: "Admin's email address", example: "admin@example.com" } + email: { + type: "string", + format: "email", + description: "Admin's email address", + example: "admin@example.com" + } }, required: ["email"] }