mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
53 - Logging, docs
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user