53 - Logging, docs

This commit is contained in:
Hendrik Belitz
2025-07-08 18:18:32 +02:00
parent 4ae8eafed5
commit c559b9e3f6
9 changed files with 447 additions and 215 deletions
+17 -10
View File
@@ -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`
- After confirmation, admin becomes `isActive: true` and `confirmed: true`
+2 -8
View File
@@ -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<typeof staff>;
export type SelectChannel = InferSelectModel<typeof channel>;
/** Appointment record type for database queries */
export type SelectAppointment = InferSelectModel<typeof appointment>;
export type SelectAppointment = InferSelectModel<typeof appointment>;
@@ -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);
});
});
});
});
+207 -65
View File
@@ -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<typeof centralSchema.admin>;
export type InsertAdminPasskey = InferInsertModel<typeof centralSchema.adminPasskey>;
@@ -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<void> {
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<void> {
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<Omit<InsertAdmin, "id" | "createdAt">>
) {
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<InsertAdminPasskey, "adminId" | "createdAt" | "updatedAt">
) {
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()
@@ -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);
});
});
});
});
+90 -18
View File
@@ -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<string, boolean | number | string> = {
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<Omit<centralSchema.InsertTenant, "id">>) {
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;
}
}
+5 -1
View File
@@ -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"]
}
+8 -2
View File
@@ -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"
}
@@ -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"]
}