diff --git a/migrations/0002_fat_pete_wisdom.sql b/migrations/0002_fat_pete_wisdom.sql new file mode 100644 index 0000000..867ce20 --- /dev/null +++ b/migrations/0002_fat_pete_wisdom.sql @@ -0,0 +1,28 @@ +CREATE TYPE "public"."setup_state" AS ENUM('NEW', 'SETTINGS_CREATED', 'AGENTS_SET_UP', 'FIRST_CHANNEL_CREATED');--> statement-breakpoint +CREATE TABLE "user_invite" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "invite_code" uuid DEFAULT gen_random_uuid() NOT NULL, + "email" text NOT NULL, + "name" text NOT NULL, + "role" "user_role" NOT NULL, + "tenant_id" uuid NOT NULL, + "invited_by" uuid NOT NULL, + "language" text DEFAULT 'de' NOT NULL, + "used" boolean DEFAULT false NOT NULL, + "used_at" timestamp, + "created_user_id" uuid, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now(), + "expires_at" timestamp NOT NULL, + CONSTRAINT "user_invite_invite_code_unique" UNIQUE("invite_code") +); +--> statement-breakpoint +ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'STAFF';--> statement-breakpoint +ALTER TABLE "tenant" ADD COLUMN "setup_state" "setup_state" DEFAULT 'NEW' NOT NULL;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "language" text DEFAULT 'de' NOT NULL;--> statement-breakpoint +ALTER TABLE "user_invite" ADD CONSTRAINT "user_invite_tenant_id_tenant_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_invite" ADD CONSTRAINT "user_invite_invited_by_user_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_invite" ADD CONSTRAINT "user_invite_created_user_id_user_id_fk" FOREIGN KEY ("created_user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "user_invite_code_idx" ON "user_invite" USING btree ("invite_code");--> statement-breakpoint +CREATE INDEX "user_invite_email_idx" ON "user_invite" USING btree ("email");--> statement-breakpoint +CREATE INDEX "user_invite_tenant_idx" ON "user_invite" USING btree ("tenant_id"); \ No newline at end of file diff --git a/migrations/meta/0002_snapshot.json b/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..c960d87 --- /dev/null +++ b/migrations/meta/0002_snapshot.json @@ -0,0 +1,767 @@ +{ + "id": "54aa77f5-cee4-419c-8c48-0d0b4b5194bc", + "prevId": "bdfafee4-65d2-4381-b4e7-df8c9337d0c1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.tenant": { + "name": "tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "short_name": { + "name": "short_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "long_name": { + "name": "long_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "database_url": { + "name": "database_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setup_state": { + "name": "setup_state", + "type": "setup_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'NEW'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "tenant_database_url_idx": { + "name": "tenant_database_url_idx", + "columns": [ + { + "expression": "database_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenant_short_name_unique": { + "name": "tenant_short_name_unique", + "nullsNotDistinct": false, + "columns": ["short_name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_config": { + "name": "tenant_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "config_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "tenant_config_tenant_name_idx": { + "name": "tenant_config_tenant_name_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tenant_config_tenant_id_tenant_id_fk": { + "name": "tenant_config_tenant_id_tenant_id_fk", + "tableFrom": "tenant_config", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'STAFF'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "confirmed": { + "name": "confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_valid_until": { + "name": "token_valid_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "passphrase_hash": { + "name": "passphrase_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recovery_passphrase": { + "name": "recovery_passphrase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'de'" + } + }, + "indexes": { + "user_email_idx": { + "name": "user_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_tenant_id_tenant_id_fk": { + "name": "user_tenant_id_tenant_id_fk", + "tableFrom": "user", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_invite": { + "name": "user_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_code": { + "name": "invite_code", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'de'" + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_user_id": { + "name": "created_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_invite_code_idx": { + "name": "user_invite_code_idx", + "columns": [ + { + "expression": "invite_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_invite_email_idx": { + "name": "user_invite_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_invite_tenant_idx": { + "name": "user_invite_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_invite_tenant_id_tenant_id_fk": { + "name": "user_invite_tenant_id_tenant_id_fk", + "tableFrom": "user_invite", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_invite_invited_by_user_id_fk": { + "name": "user_invite_invited_by_user_id_fk", + "tableFrom": "user_invite", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_invite_created_user_id_user_id_fk": { + "name": "user_invite_created_user_id_user_id_fk", + "tableFrom": "user_invite", + "tableTo": "user", + "columnsFrom": ["created_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_invite_invite_code_unique": { + "name": "user_invite_invite_code_unique", + "nullsNotDistinct": false, + "columns": ["invite_code"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_passkey": { + "name": "user_passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_passkey_user_idx": { + "name": "user_passkey_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_passkey_user_id_user_id_fk": { + "name": "user_passkey_user_id_user_id_fk", + "tableFrom": "user_passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_session": { + "name": "user_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "user_session_user_idx": { + "name": "user_session_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_session_token_idx": { + "name": "user_session_token_idx", + "columns": [ + { + "expression": "session_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_session_user_id_user_id_fk": { + "name": "user_session_user_id_user_id_fk", + "tableFrom": "user_session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_session_session_token_unique": { + "name": "user_session_session_token_unique", + "nullsNotDistinct": false, + "columns": ["session_token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.config_type": { + "name": "config_type", + "schema": "public", + "values": ["BOOLEAN", "NUMBER", "STRING"] + }, + "public.setup_state": { + "name": "setup_state", + "schema": "public", + "values": ["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 8996898..55641ed 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1752668982273, "tag": "0001_equal_wonder_man", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1758021433890, + "tag": "0002_fat_pete_wisdom", + "breakpoints": true } ] } diff --git a/src/lib/errors.ts b/src/lib/errors.ts index b87126e..62a49bc 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -1,8 +1,33 @@ export const ERRORS = { + BACKEND: { + OBFUSCATED: "Internal server error", + }, + VALIDATION: { + INVALID_REQUEST_BODY: "Invalid request body", + }, + SECURITY: { + AUTHENTICATION_REQUIRED: "Authentication required", + AUTHORIZATION_FAILED: "Insufficient permissions", + INVALID_PASSKEY_REGISTRATION: + "Invalid passkey registration. Please request a new challenge first.", + EITHER_PASSKEY_OR_PHRASE: "Either passkey or passphrase must be provided", + BOTH_PASSKEY_AND_PHRASE: "Cannot provide both passkey and passphrase", + }, TENANTS: { NAME_EXISTS: "A tenant with this short name already exists", + NO_TENANT_ID: "No tenant id given", + NOT_FOUND: "Tenant not found", + MISSING_TENANT_OR_AGENT_ID: "Missing tenant or agent ID", + }, + CHANNELS: { + NOT_FOUND: "Channel not found", }, USERS: { + ADMIN_EXISTS: "System was already initialized", EMAIL_EXISTS: "E-Mail Address already exists", + FAILED_TO_UPDATE: "Failed to update user data", + }, + AGENTS: { + NOT_FOUND: "Agent not found", }, }; diff --git a/src/lib/logger/__tests__/integration.test.ts b/src/lib/logger/__tests__/integration.test.ts index 73e823f..a95dd67 100644 --- a/src/lib/logger/__tests__/integration.test.ts +++ b/src/lib/logger/__tests__/integration.test.ts @@ -126,7 +126,7 @@ describe("UniversalLogger Integration - Client Error Forwarding", () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 500, - statusText: "Internal Server Error", + statusText: "Internal server error", }); const { createLogger } = await import("../index"); diff --git a/src/lib/server/auth/__tests__/authorization.test.ts b/src/lib/server/auth/__tests__/authorization.test.ts index cf68011..0c008af 100644 --- a/src/lib/server/auth/__tests__/authorization.test.ts +++ b/src/lib/server/auth/__tests__/authorization.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect } from "vitest"; import { AuthorizationService } from "../authorization-service"; -import { AuthenticationError } from "$lib/server/utils/errors"; +import { AuthenticationError, AuthorizationError } from "$lib/server/utils/errors"; import type { JWTPayload } from "jose"; const mockGlobalAdmin: JWTPayload = { @@ -47,7 +47,7 @@ describe("AuthorizationService", () => { it("should deny access for incorrect role", () => { expect(() => { AuthorizationService.requireRole(mockTenantAdmin, "GLOBAL_ADMIN"); - }).toThrow(AuthenticationError); + }).toThrow(AuthorizationError); }); it("should deny access for null user", () => { @@ -71,7 +71,7 @@ describe("AuthorizationService", () => { it("should deny access for disallowed role", () => { expect(() => { AuthorizationService.requireAnyRole(mockStaff, ["GLOBAL_ADMIN", "TENANT_ADMIN"]); - }).toThrow(AuthenticationError); + }).toThrow(AuthorizationError); }); }); @@ -91,7 +91,7 @@ describe("AuthorizationService", () => { it("should deny tenant admin access to other tenants", () => { expect(() => { AuthorizationService.requireTenantAccess(mockTenantAdmin, "tenant-456"); - }).toThrow(AuthenticationError); + }).toThrow(AuthorizationError); }); it("should allow staff access to their own tenant", () => { @@ -103,7 +103,7 @@ describe("AuthorizationService", () => { it("should deny staff access to other tenants", () => { expect(() => { AuthorizationService.requireTenantAccess(mockStaff, "tenant-456"); - }).toThrow(AuthenticationError); + }).toThrow(AuthorizationError); }); }); @@ -117,7 +117,7 @@ describe("AuthorizationService", () => { it("should deny non-global admin access", () => { expect(() => { AuthorizationService.requireGlobalAdmin(mockTenantAdmin); - }).toThrow(AuthenticationError); + }).toThrow(AuthorizationError); }); }); @@ -137,7 +137,7 @@ describe("AuthorizationService", () => { it("should deny staff access", () => { expect(() => { AuthorizationService.requireTenantAdmin(mockStaff); - }).toThrow(AuthenticationError); + }).toThrow(AuthorizationError); }); it("should check tenant access for tenant admin", () => { @@ -147,7 +147,7 @@ describe("AuthorizationService", () => { expect(() => { AuthorizationService.requireTenantAdmin(mockTenantAdmin, "tenant-456"); - }).toThrow(AuthenticationError); + }).toThrow(AuthorizationError); }); }); @@ -173,7 +173,7 @@ describe("AuthorizationService", () => { expect(() => { AuthorizationService.requireStaffOrAbove(mockStaff, "tenant-456"); - }).toThrow(AuthenticationError); + }).toThrow(AuthorizationError); }); }); diff --git a/src/lib/server/auth/authorization-service.ts b/src/lib/server/auth/authorization-service.ts index 0cad355..53682ac 100644 --- a/src/lib/server/auth/authorization-service.ts +++ b/src/lib/server/auth/authorization-service.ts @@ -1,6 +1,6 @@ import type { JWTPayload } from "jose"; import { UniversalLogger } from "$lib/logger"; -import { AuthenticationError } from "$lib/server/utils/errors"; +import { AuthorizationError, AuthenticationError } from "$lib/server/utils/errors"; const logger = new UniversalLogger().setContext("Authorization"); @@ -9,14 +9,14 @@ export type UserRole = "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF"; export class AuthorizationService { static requireRole(user: JWTPayload, requiredRole: UserRole): void { if (!user) { - throw new AuthenticationError("Authentication required"); + throw new AuthenticationError(); } if (user.role !== requiredRole) { logger.warn( `Access denied: User ${user.email} has role ${user.role}, required ${requiredRole}`, ); - throw new AuthenticationError("Insufficient permissions"); + throw new AuthorizationError(); } logger.debug(`Authorization granted: User ${user.email} has required role ${requiredRole}`); @@ -24,14 +24,14 @@ export class AuthorizationService { static requireAnyRole(user: JWTPayload, allowedRoles: UserRole[]): void { if (!user) { - throw new AuthenticationError("Authentication required"); + throw new AuthenticationError(); } if (!allowedRoles.includes(user.role as UserRole)) { logger.warn( `Access denied: User ${user.email} has role ${user.role}, allowed roles: ${allowedRoles.join(", ")}`, ); - throw new AuthenticationError("Insufficient permissions"); + throw new AuthorizationError(); } logger.debug(`Authorization granted: User ${user.email} has allowed role ${user.role}`); @@ -39,7 +39,7 @@ export class AuthorizationService { static requireTenantAccess(user: JWTPayload, tenantId: string): void { if (!user) { - throw new AuthenticationError("Authentication required"); + throw new AuthenticationError(); } if (user.role === "GLOBAL_ADMIN") { @@ -52,14 +52,14 @@ export class AuthorizationService { if (user.role === "TENANT_ADMIN" || user.role === "STAFF") { if (!user.tenantId) { logger.warn(`Access denied: User ${user.email} has no tenant assigned`); - throw new AuthenticationError("No tenant access"); + throw new AuthorizationError("No tenant access"); } if (user.tenantId !== tenantId) { logger.warn( `Access denied: User ${user.email} trying to access tenant ${tenantId}, but belongs to ${user.tenantId}`, ); - throw new AuthenticationError("Tenant access denied"); + throw new AuthorizationError("Tenant access denied"); } logger.debug(`Authorization granted: User ${user.email} accessing own tenant ${tenantId}`); @@ -67,7 +67,7 @@ export class AuthorizationService { } logger.warn(`Access denied: User ${user.email} has invalid role ${user.role}`); - throw new AuthenticationError("Invalid role"); + throw new AuthorizationError("Invalid role"); } static requireGlobalAdmin(user: JWTPayload): void { diff --git a/src/lib/server/utils/errors.ts b/src/lib/server/utils/errors.ts index 95bec9c..38d7307 100644 --- a/src/lib/server/utils/errors.ts +++ b/src/lib/server/utils/errors.ts @@ -1,5 +1,17 @@ +import { ERRORS } from "$lib/errors"; +import type { UniversalLogger } from "$lib/logger"; import { json } from "@sveltejs/kit"; +export const logError = + (logger: UniversalLogger) => + (message: string, error: unknown, requestedBy?: string, tenantId?: string) => { + logger.error(message, { + tenantId, + requestedBy, + error: JSON.stringify(error || "?"), + }); + }; + export class BackendError extends Error { constructor( message: string, @@ -8,13 +20,23 @@ export class BackendError extends Error { super(message); } - public toJson = () => json({ message: this.message }, { status: this.code }); + public toJson = () => json({ error: this.message, message: this.message }, { status: this.code }); +} + +export class AuthorizationError extends BackendError { + constructor( + message: string = ERRORS.SECURITY.AUTHORIZATION_FAILED, + public code = 403, + ) { + super(message, code); + this.name = "AuthorizationError"; + } } export class AuthenticationError extends BackendError { constructor( - message: string, - public code = 403, + message: string = ERRORS.SECURITY.AUTHENTICATION_REQUIRED, + public code = 401, ) { super(message, code); this.name = "AuthenticationError"; @@ -50,3 +72,13 @@ export class ConflictError extends BackendError { this.name = "ConflictError"; } } + +export class InternalError extends BackendError { + constructor( + message: string = ERRORS.BACKEND.OBFUSCATED, + public code = 500, + ) { + super(message, code); + this.name = "InternalError"; + } +} diff --git a/src/lib/server/utils/permissions.ts b/src/lib/server/utils/permissions.ts index 639aafc..ca92996 100644 --- a/src/lib/server/utils/permissions.ts +++ b/src/lib/server/utils/permissions.ts @@ -1,4 +1,4 @@ -import { json } from "@sveltejs/kit"; +import { AuthenticationError, AuthorizationError } from "./errors"; /** * Check whether the user can access the data within a route. @@ -10,29 +10,32 @@ export const checkPermission = ( locals: App.Locals, tenantId: string | null, administrative: boolean = false, -): Response | null => { + global: boolean = false, +): void => { if (!locals.user) { - return json({ error: "Authentication required" }, { status: 401 }); + throw new AuthenticationError(); } if (locals.user.role === "GLOBAL_ADMIN") { // Global admin can view absences for any tenant - return null; + return; } else if ( + !global && locals.user.role === "TENANT_ADMIN" && tenantId != null && locals.user.tenantId === tenantId ) { // Tenant admin and staff can view absences for their own tenant - return null; + return; } else if ( + !global && !administrative && locals.user.role === "STAFF" && tenantId != null && locals.user.tenantId === tenantId ) { // Tenant admin and staff can view absences for their own tenant - return null; + return; } else { - return json({ error: "Insufficient permissions" }, { status: 403 }); + throw new AuthorizationError(); } }; diff --git a/src/routes/api/admin/exists/+server.ts b/src/routes/api/admin/exists/+server.ts index 9e87ccd..44ba1ca 100644 --- a/src/routes/api/admin/exists/+server.ts +++ b/src/routes/api/admin/exists/+server.ts @@ -3,6 +3,7 @@ import { UserService } from "$lib/server/services/user-service"; import type { RequestHandler } from "./$types"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; +import { BackendError, InternalError, logError } from "$lib/server/utils/errors"; // Register OpenAPI documentation registerOpenAPIRoute("/admin/exists", "GET", { @@ -63,7 +64,11 @@ export const GET: RequestHandler = async () => { count: adminCount, }); } catch (error) { - log.error("Error checking admin existence:", String(error)); - return json({ error: "Internal server error" }, { status: 500 }); + logError(log)("Error checking admin existence", error); + + if (error instanceof BackendError) { + return error.toJson(); + } + return new InternalError().toJson(); } }; diff --git a/src/routes/api/admin/exists/server.test.ts b/src/routes/api/admin/exists/server.test.ts index 86387b2..043204c 100644 --- a/src/routes/api/admin/exists/server.test.ts +++ b/src/routes/api/admin/exists/server.test.ts @@ -66,9 +66,7 @@ describe("GET /api/admin/exists", () => { const data = await response.json(); expect(response.status).toBe(500); - expect(data).toEqual({ - error: "Internal server error", - }); + expect(data.error).toBe("Internal server error"); expect(UserService.adminExists).toHaveBeenCalledOnce(); expect(UserService.getAdminCount).not.toHaveBeenCalled(); }); @@ -81,9 +79,7 @@ describe("GET /api/admin/exists", () => { const data = await response.json(); expect(response.status).toBe(500); - expect(data).toEqual({ - error: "Internal server error", - }); + expect(data.error).toBe("Internal server error"); expect(UserService.adminExists).toHaveBeenCalledOnce(); expect(UserService.getAdminCount).toHaveBeenCalledOnce(); }); diff --git a/src/routes/api/admin/init/+server.ts b/src/routes/api/admin/init/+server.ts index 78740a6..685978a 100644 --- a/src/routes/api/admin/init/+server.ts +++ b/src/routes/api/admin/init/+server.ts @@ -1,10 +1,17 @@ import { json } from "@sveltejs/kit"; import { UserService } from "$lib/server/services/user-service"; import { WebAuthnService } from "$lib/server/auth/webauthn-service"; -import { ValidationError } from "$lib/server/utils/errors"; +import { + BackendError, + ConflictError, + InternalError, + logError, + ValidationError, +} from "$lib/server/utils/errors"; import type { RequestHandler } from "./$types"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; +import { ERRORS } from "$lib/errors"; // Register OpenAPI documentation registerOpenAPIRoute("/admin/init", "POST", { @@ -113,7 +120,7 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => { const body = await request.json(); if (await UserService.adminExists()) { - return json({ error: "System was already initialized" }, { status: 409 }); + throw new ConflictError(ERRORS.USERS.ADMIN_EXISTS); } // Validate that either passkey or passphrase is provided (but not both) @@ -121,11 +128,11 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => { const hasPassphrase = !!body.passphrase; if (!hasPasskey && !hasPassphrase) { - return json({ error: "Either passkey or passphrase must be provided" }, { status: 400 }); + throw new ValidationError(ERRORS.SECURITY.EITHER_PASSKEY_OR_PHRASE); } if (hasPasskey && hasPassphrase) { - return json({ error: "Cannot provide both passkey and passphrase" }, { status: 400 }); + throw new ValidationError(ERRORS.SECURITY.BOTH_PASSKEY_AND_PHRASE); } log.debug("Creating admin account", { @@ -152,10 +159,7 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => { const registrationEmail = cookies.get("webauthn-registration-email"); if (!registrationEmail || registrationEmail !== body.email) { - return json( - { error: "Invalid passkey registration. Please request a new challenge first." }, - { status: 400 }, - ); + throw new ValidationError(ERRORS.SECURITY.INVALID_PASSKEY_REGISTRATION); } // Clear the registration cookie after validation (challenge cookie is cleared by login route) @@ -193,17 +197,17 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => { { status: 201 }, ); } catch (error) { - log.error("Admin registration error:", JSON.stringify(error || "?")); + logError(log)("Admin registration error", error); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); + if (error instanceof BackendError) { + return error.toJson(); } // Handle unique constraint violation (email already exists) if (error instanceof Error && error.message.includes("unique constraint")) { - return json({ error: "An admin with this email already exists" }, { status: 409 }); + return new ConflictError("An admin with this email already exists").toJson(); } - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; diff --git a/src/routes/api/admin/tenant/+server.ts b/src/routes/api/admin/tenant/+server.ts index 22e02d1..cf801ee 100644 --- a/src/routes/api/admin/tenant/+server.ts +++ b/src/routes/api/admin/tenant/+server.ts @@ -1,14 +1,21 @@ -import { error, json, type RequestHandler } from "@sveltejs/kit"; +import { json, type RequestHandler } from "@sveltejs/kit"; import { z } from "zod"; import { centralDb } from "$lib/server/db"; import { tenant } from "$lib/server/db/central-schema"; import { eq } from "drizzle-orm"; -import { ValidationError, NotFoundError } from "$lib/server/utils/errors"; +import { + BackendError, + InternalError, + logError, + NotFoundError, + ValidationError, +} from "$lib/server/utils/errors"; import { UserService } from "$lib/server/services/user-service"; import { generateAccessToken } from "$lib/server/auth/jwt-utils"; import { UniversalLogger } from "$lib/logger"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import { checkPermission } from "$lib/server/utils/permissions"; +import { ERRORS } from "$lib/errors"; const logger = new UniversalLogger().setContext("AdminTenantSwitch"); @@ -23,10 +30,7 @@ const tenantSwitchSchema = z.object({ export const POST: RequestHandler = async ({ request, locals, cookies }) => { try { // Verify user is authenticated and is global admin - const permissionError = checkPermission(locals, null, true); - if (permissionError) { - return permissionError; - } + checkPermission(locals, null, true); const body = await request.json(); const validation = tenantSwitchSchema.safeParse(body); @@ -36,7 +40,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => { userId: locals.user?.id, errors: validation.error.errors, }); - throw error(400, "Invalid request body"); + throw new ValidationError(ERRORS.VALIDATION.INVALID_REQUEST_BODY); } const { tenantId } = validation.data; @@ -53,7 +57,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => { userId: locals.user?.id, tenantId, }); - throw error(404, "Tenant not found"); + throw new NotFoundError(ERRORS.TENANTS.NOT_FOUND); } // Current user is already authenticated via authHandle @@ -68,7 +72,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => { userId: locals.user?.userId, tenantId, }); - throw error(500, "Failed to update user data"); + throw new InternalError(ERRORS.USERS.FAILED_TO_UPDATE); } // Generate new access token with updated tenant context @@ -105,18 +109,12 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => { }, }); } catch (err) { - if (err instanceof ValidationError) { - logger.warn("Validation error in tenant switch", { error: err.message }); - throw error(400, err.message); - } + logError(logger)("Error in tenant switch", err, locals.user?.userId); - if (err instanceof NotFoundError) { - logger.warn("Not found error in tenant switch", { error: err.message }); - throw error(404, err.message); + if (err instanceof BackendError) { + return err.toJson(); } - - logger.error("Unexpected error in tenant switch", { error: String(err) }); - throw error(500, "Internal server error"); + return new InternalError().toJson(); } }; diff --git a/src/routes/api/admin/tenant/server.test.ts b/src/routes/api/admin/tenant/server.test.ts index d26f74e..86301b5 100644 --- a/src/routes/api/admin/tenant/server.test.ts +++ b/src/routes/api/admin/tenant/server.test.ts @@ -14,8 +14,9 @@ vi.mock("@sveltejs/kit", async () => { (error as any).body = { message }; throw error; }), - json: vi.fn((data: any) => { - return new Response(JSON.stringify(data), { status: 200 }); + json: vi.fn((data: any, options: any = {}) => { + const status = options.status || 200; + return new Response(JSON.stringify(data), { status }); }), }; }); @@ -83,12 +84,6 @@ vi.mock("drizzle-orm", () => ({ eq: vi.fn(() => "eq-condition"), })); -// Mock error classes -vi.mock("$lib/server/utils/errors", () => ({ - ValidationError: class ValidationError extends Error {}, - NotFoundError: class NotFoundError extends Error {}, -})); - // Mock permissions module vi.mock("$lib/server/utils/permissions", () => ({ checkPermission: vi.fn(), @@ -98,6 +93,7 @@ import { centralDb } from "$lib/server/db"; import { UserService } from "$lib/server/services/user-service"; import { generateAccessToken } from "$lib/server/auth/jwt-utils"; import { checkPermission } from "$lib/server/utils/permissions"; +import { AuthenticationError, AuthorizationError } from "$lib/server/utils/errors"; describe("POST /api/admin/tenant", () => { const mockUser = { @@ -144,14 +140,17 @@ describe("POST /api/admin/tenant", () => { beforeEach(() => { vi.clearAllMocks(); + // Reset checkPermission mock to not throw by default + vi.mocked(checkPermission).mockImplementation(() => { + // Default implementation that doesn't throw + }); }); it("should successfully switch to a tenant", async () => { const tenantId = "550e8400-e29b-41d4-a716-446655440000"; const requestEvent = createRequestEvent({ tenantId }); - // Mock permission check to pass - vi.mocked(checkPermission).mockReturnValue(null); + // Permission check will use default mock (pass) // Mock tenant exists query const mockSelectQuery = { @@ -197,11 +196,10 @@ describe("POST /api/admin/tenant", () => { null, ); - // Mock permission check to return 401 error - const mockErrorResponse = new Response(JSON.stringify({ error: "Authentication required" }), { - status: 401, + // Mock permission check to throw authentication error + vi.mocked(checkPermission).mockImplementationOnce(() => { + throw new AuthenticationError("Authentication required"); }); - vi.mocked(checkPermission).mockReturnValue(mockErrorResponse); const response = await POST(requestEvent); expect(response.status).toBe(401); @@ -216,11 +214,10 @@ describe("POST /api/admin/tenant", () => { tenantAdminUser, ); - // Mock permission check to return 403 error - const mockErrorResponse = new Response(JSON.stringify({ error: "Insufficient permissions" }), { - status: 403, + // Mock permission check to throw authorization error + vi.mocked(checkPermission).mockImplementationOnce(() => { + throw new AuthorizationError("Insufficient permissions"); }); - vi.mocked(checkPermission).mockReturnValue(mockErrorResponse); const response = await POST(requestEvent); expect(response.status).toBe(403); @@ -228,20 +225,22 @@ describe("POST /api/admin/tenant", () => { expect(result.error).toBe("Insufficient permissions"); }); - it("should return 400 when request body is invalid", async () => { + it("should return 422 when request body is invalid", async () => { const requestEvent = createRequestEvent({ tenantId: "invalid-uuid" }); - // Mock permission check to pass - vi.mocked(checkPermission).mockReturnValue(null); + // Permission check will use default mock (pass) - await expect(POST(requestEvent)).rejects.toThrow(); + const response = await POST(requestEvent); + const result = await response.json(); + + expect(response.status).toBe(422); + expect(result.error).toBe("Invalid request body"); }); it("should return 404 when tenant does not exist", async () => { const requestEvent = createRequestEvent({ tenantId: "550e8400-e29b-41d4-a716-446655440000" }); - // Mock permission check to pass - vi.mocked(checkPermission).mockReturnValue(null); + // Permission check will use default mock (pass) // Mock tenant doesn't exist const mockSelectQuery = { @@ -251,6 +250,10 @@ describe("POST /api/admin/tenant", () => { }; vi.mocked(centralDb.select).mockReturnValue(mockSelectQuery as any); - await expect(POST(requestEvent)).rejects.toThrow(); + const response = await POST(requestEvent); + const result = await response.json(); + + expect(response.status).toBe(404); + expect(result.error).toBe("Tenant not found"); }); }); diff --git a/src/routes/api/health/services/+server.ts b/src/routes/api/health/services/+server.ts index da4a6eb..af8819f 100644 --- a/src/routes/api/health/services/+server.ts +++ b/src/routes/api/health/services/+server.ts @@ -34,7 +34,7 @@ registerOpenAPIRoute("/health/services", "GET", { }, }, "500": { - description: "Internal Server Error", + description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" }, diff --git a/src/routes/api/tenants/+server.ts b/src/routes/api/tenants/+server.ts index 29e1594..6288b37 100644 --- a/src/routes/api/tenants/+server.ts +++ b/src/routes/api/tenants/+server.ts @@ -1,6 +1,6 @@ import { json } from "@sveltejs/kit"; import { TenantAdminService } from "$lib/server/services/tenant-admin-service"; -import { BackendError } from "$lib/server/utils/errors"; +import { BackendError, ConflictError, InternalError, logError } from "$lib/server/utils/errors"; import type { RequestHandler } from "@sveltejs/kit"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import { db } from "$lib/server/db"; @@ -160,10 +160,7 @@ export const POST: RequestHandler = async ({ locals, request }) => { hasInviteAdmin: !!body.inviteAdmin, }); - const error = checkPermission(locals, null, true); - if (error) { - return error; - } + checkPermission(locals, null, true); const tenantService = await TenantAdminService.createTenant({ shortName: body.shortName, @@ -184,18 +181,17 @@ export const POST: RequestHandler = async ({ locals, request }) => { { status: 201 }, ); } catch (error) { - log.error("Tenant creation error:", JSON.stringify(error || "?")); + logError(log)("Tenant creation error", error, locals.user?.userId); if (error instanceof BackendError) { return error.toJson(); } - // Handle unique constraint violation (shortName already exists) if (error instanceof Error && error.message.includes("unique constraint")) { - return json({ error: ERRORS.TENANTS.NAME_EXISTS }, { status: 409 }); + return new ConflictError(ERRORS.TENANTS.NAME_EXISTS).toJson(); } - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; @@ -204,16 +200,10 @@ export const GET: RequestHandler = async ({ locals }) => { try { // Check if user is authenticated and is a global admin - if (!locals.user) { - return json({ error: "Authentication required" }, { status: 401 }); - } - - if (locals.user.role !== "GLOBAL_ADMIN") { - return json({ error: "Global admin access required" }, { status: 403 }); - } + checkPermission(locals, null, true, true); log.debug("Getting all tenants", { - requestedBy: locals.user.userId, + requestedBy: locals.user?.userId, }); // Get all tenants from database @@ -230,14 +220,14 @@ export const GET: RequestHandler = async ({ locals }) => { log.debug("Retrieved tenants successfully", { tenantCount: tenants.length, - requestedBy: locals.user.userId, + requestedBy: locals.user?.userId, }); return json({ tenants: tenants, }); } catch (error) { - log.error("Failed to get tenants:", JSON.stringify(error || "?")); - return json({ error: "Internal server error" }, { status: 500 }); + logError(log)("Error getting tenants", error, locals.user?.userId); + return new InternalError().toJson(); } }; diff --git a/src/routes/api/tenants/[id]/+server.ts b/src/routes/api/tenants/[id]/+server.ts index b706dd9..1d0d2b6 100644 --- a/src/routes/api/tenants/[id]/+server.ts +++ b/src/routes/api/tenants/[id]/+server.ts @@ -1,7 +1,13 @@ import { json } from "@sveltejs/kit"; import { TenantAdminService } from "$lib/server/services/tenant-admin-service"; -import { ValidationError, NotFoundError } from "$lib/server/utils/errors"; -import { AuthorizationService } from "$lib/server/auth/authorization-service"; +import { + ValidationError, + BackendError, + ConflictError, + InternalError, + logError, + NotFoundError, +} from "$lib/server/utils/errors"; import type { RequestHandler } from "@sveltejs/kit"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; @@ -395,13 +401,10 @@ export const PUT: RequestHandler = async ({ locals, params, request }) => { }); if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); + throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID); } - const error = checkPermission(locals, tenantId, true); - if (error) { - return error; - } + checkPermission(locals, tenantId, true); const tenantService = await TenantAdminService.getTenantById(tenantId); const updatedTenant = await tenantService.updateTenantData(body); @@ -416,22 +419,18 @@ export const PUT: RequestHandler = async ({ locals, params, request }) => { tenant: updatedTenant, }); } catch (error) { - log.error("Error updating tenant metadata:", JSON.stringify(error || "?")); + logError(log)("Error updating tenant metadata", error, locals.user?.userId, params.id); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); - } - - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); + if (error instanceof BackendError) { + return error.toJson(); } // Handle unique constraint violation (shortName already exists) if (error instanceof Error && error.message.includes("unique constraint")) { - return json({ error: ERRORS.TENANTS.NAME_EXISTS }, { status: 409 }); + return new ConflictError(ERRORS.TENANTS.NAME_EXISTS).toJson(); } - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; @@ -442,7 +441,7 @@ export const GET: RequestHandler = async ({ params, locals }) => { const tenantId = params.id; if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); + throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID); } log.debug("Getting tenant details", { @@ -450,16 +449,13 @@ export const GET: RequestHandler = async ({ params, locals }) => { requestedBy: locals.user?.userId, }); - const error = checkPermission(locals, tenantId, true); - if (error) { - return error; - } + checkPermission(locals, tenantId, true); const tenantService = await TenantAdminService.getTenantById(tenantId); const tenantData = tenantService.tenantData; if (!tenantData) { - return json({ error: "Tenant not found" }, { status: 404 }); + throw new NotFoundError(ERRORS.TENANTS.NOT_FOUND); } log.debug("Tenant details retrieved successfully", { @@ -471,13 +467,13 @@ export const GET: RequestHandler = async ({ params, locals }) => { tenant: tenantData, }); } catch (error) { - log.error("Error getting tenant details:", JSON.stringify(error || "?")); + logError(log)("Error getting tenant details", error, locals.user?.userId, params.id); - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); + if (error instanceof BackendError) { + return error.toJson(); } - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; @@ -488,32 +484,18 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { const tenantId = params.id; // Check if user is authenticated - if (!locals.user) { - return json({ error: "Authentication required" }, { status: 401 }); - } + checkPermission(locals, tenantId ?? null, true, true); if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); + throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID); } log.info("Attempting tenant deletion", { tenantId, - requestedBy: locals.user.userId, - userRole: locals.user.role, + requestedBy: locals.user?.userId, + userRole: locals.user?.role, }); - // Authorization: Only global admins can delete tenants - try { - AuthorizationService.requireGlobalAdmin(locals.user); - } catch { - log.warn("Tenant deletion denied: insufficient permissions", { - tenantId, - requestedBy: locals.user.userId, - userRole: locals.user.role, - }); - return json({ error: "Insufficient permissions" }, { status: 403 }); - } - // Get tenant service and perform deletion const tenantService = await TenantAdminService.getTenantById(tenantId); const result = await tenantService.deleteTenant(); @@ -524,7 +506,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { deletedUsersCount: result.deletedUsersCount, updatedGlobalAdminsCount: result.updatedGlobalAdminsCount, deletedConfigsCount: result.deletedConfigsCount, - requestedBy: locals.user.userId, + requestedBy: locals.user?.userId, }); return json({ @@ -532,16 +514,12 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { result, }); } catch (error) { - log.error("Error deleting tenant", { - tenantId: params.id, - requestedBy: locals.user?.userId, - error: JSON.stringify(error || "?"), - }); + logError(log)("Error deleting tenant", error, locals.user?.userId, params.id); - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); + if (error instanceof BackendError) { + return error.toJson(); } - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; diff --git a/src/routes/api/tenants/[id]/agents/+server.ts b/src/routes/api/tenants/[id]/agents/+server.ts index 17edd56..207b445 100644 --- a/src/routes/api/tenants/[id]/agents/+server.ts +++ b/src/routes/api/tenants/[id]/agents/+server.ts @@ -1,10 +1,11 @@ import { json } from "@sveltejs/kit"; import { AgentService } from "$lib/server/services/agent-service"; -import { ValidationError, NotFoundError } from "$lib/server/utils/errors"; +import { ValidationError, logError, BackendError, InternalError } from "$lib/server/utils/errors"; import type { RequestHandler } from "@sveltejs/kit"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; import { checkPermission } from "$lib/server/utils/permissions"; +import { ERRORS } from "$lib/errors"; // Register OpenAPI documentation for POST registerOpenAPIRoute("/tenants/{id}/agents", "POST", { @@ -204,13 +205,10 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { // Check if user is authenticated if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); + throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID); } - const error = checkPermission(locals, tenantId, true); - if (error) { - return error; - } + checkPermission(locals, tenantId, true); const body = await request.json(); @@ -237,17 +235,13 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { { status: 201 }, ); } catch (error) { - log.error("Error creating agent:", JSON.stringify(error || "?")); + logError(log)("Error creating agent:", error, locals.user?.userId, params.id); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); + if (error instanceof BackendError) { + return error.toJson(); } - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); - } - - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; @@ -259,13 +253,10 @@ export const GET: RequestHandler = async ({ params, locals }) => { // Check if user is authenticated if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); + throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID); } - const error = checkPermission(locals, tenantId); - if (error) { - return error; - } + checkPermission(locals, tenantId); log.debug("Getting all agents", { tenantId, @@ -285,12 +276,12 @@ export const GET: RequestHandler = async ({ params, locals }) => { agents, }); } catch (error) { - log.error("Error getting agents:", JSON.stringify(error || "?")); + logError(log)("Error getting agents:", error, locals.user?.userId, params.id); - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); + if (error instanceof BackendError) { + return error.toJson(); } - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; diff --git a/src/routes/api/tenants/[id]/agents/[agentId]/+server.ts b/src/routes/api/tenants/[id]/agents/[agentId]/+server.ts index 36aa61b..6a2461e 100644 --- a/src/routes/api/tenants/[id]/agents/[agentId]/+server.ts +++ b/src/routes/api/tenants/[id]/agents/[agentId]/+server.ts @@ -1,10 +1,17 @@ import { json } from "@sveltejs/kit"; import { AgentService } from "$lib/server/services/agent-service"; -import { ValidationError, NotFoundError } from "$lib/server/utils/errors"; +import { + ValidationError, + NotFoundError, + logError, + BackendError, + InternalError, +} from "$lib/server/utils/errors"; import type { RequestHandler } from "@sveltejs/kit"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; import { checkPermission } from "$lib/server/utils/permissions"; +import { ERRORS } from "$lib/errors"; // Register OpenAPI documentation for GET registerOpenAPIRoute("/tenants/{id}/agents/{agentId}", "GET", { @@ -287,13 +294,10 @@ export const GET: RequestHandler = async ({ params, locals }) => { // Check if user is authenticated if (!tenantId || !agentId) { - return json({ error: "Missing tenant or agent ID" }, { status: 400 }); + throw new ValidationError(ERRORS.TENANTS.MISSING_TENANT_OR_AGENT_ID); } - const error = checkPermission(locals, tenantId); - if (error) { - return error; - } + checkPermission(locals, tenantId); log.debug("Getting agent details", { tenantId, @@ -305,7 +309,7 @@ export const GET: RequestHandler = async ({ params, locals }) => { const agent = await agentService.getAgentById(agentId); if (!agent) { - return json({ error: "Agent not found" }, { status: 404 }); + throw new NotFoundError("Agent not found"); } log.debug("Agent details retrieved successfully", { @@ -318,13 +322,13 @@ export const GET: RequestHandler = async ({ params, locals }) => { agent, }); } catch (error) { - log.error("Error getting agent details:", JSON.stringify(error || "?")); + logError(log)("Error getting agent details", error, locals.user?.userId, params.id); - if (error instanceof NotFoundError) { - return json({ error: "Agent not found" }, { status: 404 }); + if (error instanceof BackendError) { + return error.toJson(); } - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; @@ -337,14 +341,10 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { // Check if user is authenticated if (!tenantId || !agentId) { - return json({ error: "Missing tenant or agent ID" }, { status: 400 }); - } - - const error = checkPermission(locals, tenantId, true); - if (error) { - return error; + throw new ValidationError(ERRORS.TENANTS.MISSING_TENANT_OR_AGENT_ID); } + checkPermission(locals, tenantId, true); const body = await request.json(); log.debug("Updating agent", { @@ -368,17 +368,13 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { agent: updatedAgent, }); } catch (error) { - log.error("Error updating agent:", JSON.stringify(error || "?")); + logError(log)("Error updating agent", error, locals.user?.userId, params.id); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); + if (error instanceof BackendError) { + return error.toJson(); } - if (error instanceof NotFoundError) { - return json({ error: "Agent not found" }, { status: 404 }); - } - - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; @@ -390,13 +386,10 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { const agentId = params.agentId; if (!tenantId || !agentId) { - return json({ error: "Missing tenant or agent ID" }, { status: 400 }); + throw new ValidationError(ERRORS.TENANTS.MISSING_TENANT_OR_AGENT_ID); } - const error = checkPermission(locals, tenantId, true); - if (error) { - return error; - } + checkPermission(locals, tenantId, true); log.debug("Deleting agent", { tenantId, @@ -408,7 +401,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { const deleted = await agentService.deleteAgent(agentId); if (!deleted) { - return json({ error: "Agent not found" }, { status: 404 }); + throw new NotFoundError(ERRORS.AGENTS.NOT_FOUND); } log.debug("Agent deleted successfully", { @@ -421,12 +414,12 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { message: "Agent deleted successfully", }); } catch (error) { - log.error("Error deleting agent:", JSON.stringify(error || "?")); + logError(log)("Error deleting agent", error, locals.user?.userId, params.id); - if (error instanceof NotFoundError) { - return json({ error: "Agent not found" }, { status: 404 }); + if (error instanceof BackendError) { + return error.toJson(); } - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; diff --git a/src/routes/api/tenants/[id]/agents/[agentId]/__tests__/agent-detail-api.test.ts b/src/routes/api/tenants/[id]/agents/[agentId]/__tests__/agent-detail-api.test.ts index 8d9a9ca..bab86c5 100644 --- a/src/routes/api/tenants/[id]/agents/[agentId]/__tests__/agent-detail-api.test.ts +++ b/src/routes/api/tenants/[id]/agents/[agentId]/__tests__/agent-detail-api.test.ts @@ -250,7 +250,7 @@ describe("Agent Detail API Routes", () => { const response = await PUT(event); const data = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(422); expect(data.error).toBe("Invalid agent data"); }); diff --git a/src/routes/api/tenants/[id]/agents/[agentId]/absences/+server.ts b/src/routes/api/tenants/[id]/agents/[agentId]/absences/+server.ts index 1376870..ee6bd53 100644 --- a/src/routes/api/tenants/[id]/agents/[agentId]/absences/+server.ts +++ b/src/routes/api/tenants/[id]/agents/[agentId]/absences/+server.ts @@ -1,6 +1,6 @@ import { json } from "@sveltejs/kit"; import { AgentService } from "$lib/server/services/agent-service"; -import { ValidationError, NotFoundError, ConflictError } from "$lib/server/utils/errors"; +import { ValidationError, logError, BackendError, InternalError } from "$lib/server/utils/errors"; import type { RequestHandler } from "@sveltejs/kit"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; @@ -258,13 +258,10 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { const agentId = params.agentId; if (!tenantId || !agentId) { - return json({ error: "Missing tenant or agent ID" }, { status: 400 }); + throw new ValidationError("Missing tenant or agent ID"); } - const error = checkPermission(locals, tenantId); - if (error) { - return error; - } + checkPermission(locals, tenantId); const body = await request.json(); @@ -301,21 +298,13 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { { status: 201 }, ); } catch (error) { - log.error("Error creating agent absence:", JSON.stringify(error || "?")); + logError(log)("Creating agent absence failed", error, locals.user?.userId, params.id); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); + if (error instanceof BackendError) { + return error.toJson(); } - if (error instanceof NotFoundError) { - return json({ error: "Agent not found" }, { status: 404 }); - } - - if (error instanceof ConflictError) { - return json({ error: error.message }, { status: 409 }); - } - - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; @@ -327,13 +316,10 @@ export const GET: RequestHandler = async ({ params, locals, url }) => { const agentId = params.agentId; if (!tenantId || !agentId) { - return json({ error: "Missing tenant or agent ID" }, { status: 400 }); + throw new ValidationError("Missing tenant or agent ID"); } - const error = checkPermission(locals, tenantId); - if (error) { - return error; - } + checkPermission(locals, tenantId); // Get optional query parameters for date filtering const startDate = url.searchParams.get("startDate"); @@ -365,16 +351,12 @@ export const GET: RequestHandler = async ({ params, locals, url }) => { absences, }); } catch (error) { - log.error("Error getting agent absences:", JSON.stringify(error || "?")); + logError(log)("Getting agent absences failed", error, locals.user?.userId, params.id); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); + if (error instanceof BackendError) { + return error.toJson(); } - if (error instanceof NotFoundError) { - return json({ error: "Agent not found" }, { status: 404 }); - } - - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; diff --git a/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/+server.ts b/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/+server.ts index c3f8f06..9a230a9 100644 --- a/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/+server.ts +++ b/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/+server.ts @@ -1,6 +1,12 @@ import { json } from "@sveltejs/kit"; import { AgentService } from "$lib/server/services/agent-service"; -import { ValidationError, NotFoundError, ConflictError } from "$lib/server/utils/errors"; +import { + ValidationError, + NotFoundError, + logError, + BackendError, + InternalError, +} from "$lib/server/utils/errors"; import type { RequestHandler } from "@sveltejs/kit"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; @@ -334,13 +340,10 @@ export const GET: RequestHandler = async ({ params, locals }) => { // Check if user is authenticated if (!tenantId || !agentId || !absenceId) { - return json({ error: "Missing tenant, agent, or absence ID" }, { status: 400 }); + throw new ValidationError("Missing tenant, agent, or absence ID"); } - const error = checkPermission(locals, tenantId); - if (error) { - return error; - } + checkPermission(locals, tenantId); log.debug("Getting absence details", { tenantId, @@ -353,12 +356,12 @@ export const GET: RequestHandler = async ({ params, locals }) => { const absence = await agentService.getAbsenceById(absenceId); if (!absence) { - return json({ error: "Absence not found" }, { status: 404 }); + throw new NotFoundError("Absence not found"); } // Verify the absence belongs to the requested agent if (absence.agentId !== agentId) { - return json({ error: "Absence not found for this agent" }, { status: 404 }); + throw new NotFoundError("Absence not found for this agent"); } log.debug("Absence details retrieved successfully", { @@ -372,13 +375,13 @@ export const GET: RequestHandler = async ({ params, locals }) => { absence, }); } catch (error) { - log.error("Error getting absence details:", JSON.stringify(error || "?")); + logError(log)("Error getting absence details", error, locals.user?.userId, params.id); - if (error instanceof NotFoundError) { - return json({ error: "Absence not found" }, { status: 404 }); + if (error instanceof BackendError) { + return error.toJson(); } - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; @@ -392,13 +395,10 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { // Check if user is authenticated if (!tenantId || !agentId || !absenceId) { - return json({ error: "Missing tenant, agent, or absence ID" }, { status: 400 }); + throw new ValidationError("Missing tenant, agent, or absence ID"); } - const error = checkPermission(locals, tenantId); - if (error) { - return error; - } + checkPermission(locals, tenantId); const body = await request.json(); @@ -415,11 +415,11 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { // First verify the absence exists and belongs to the agent const existingAbsence = await agentService.getAbsenceById(absenceId); if (!existingAbsence) { - return json({ error: "Absence not found" }, { status: 404 }); + throw new NotFoundError("Absence not found"); } if (existingAbsence.agentId !== agentId) { - return json({ error: "Absence not found for this agent" }, { status: 404 }); + throw new NotFoundError("Absence not found for this agent"); } const updatedAbsence = await agentService.updateAbsence(absenceId, body); @@ -436,21 +436,13 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { absence: updatedAbsence, }); } catch (error) { - log.error("Error updating agent absence:", JSON.stringify(error || "?")); + logError(log)("Error updating agent absence", error, locals.user?.userId, params.id); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); + if (error instanceof BackendError) { + return error.toJson(); } - if (error instanceof NotFoundError) { - return json({ error: "Absence not found" }, { status: 404 }); - } - - if (error instanceof ConflictError) { - return json({ error: error.message }, { status: 409 }); - } - - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; @@ -463,13 +455,10 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { const absenceId = params.absenceId; if (!tenantId || !agentId || !absenceId) { - return json({ error: "Missing tenant, agent, or absence ID" }, { status: 400 }); + throw new ValidationError("Missing tenant, agent, or absence ID"); } - const error = checkPermission(locals, tenantId); - if (error) { - return error; - } + checkPermission(locals, tenantId); log.debug("Deleting agent absence", { tenantId, @@ -483,11 +472,11 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { // First verify the absence exists and belongs to the agent const existingAbsence = await agentService.getAbsenceById(absenceId); if (!existingAbsence) { - return json({ error: "Absence not found" }, { status: 404 }); + throw new NotFoundError("Absence not found"); } if (existingAbsence.agentId !== agentId) { - return json({ error: "Absence not found for this agent" }, { status: 404 }); + throw new NotFoundError("Absence not found for this agent"); } const deleted = await agentService.deleteAbsence(absenceId); @@ -507,12 +496,12 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { message: "Absence deleted successfully", }); } catch (error) { - log.error("Error deleting agent absence:", JSON.stringify(error || "?")); + logError(log)("Error deleting agent absence", error, locals.user?.userId, params.id); - if (error instanceof NotFoundError) { - return json({ error: "Absence not found" }, { status: 404 }); + if (error instanceof BackendError) { + return error.toJson(); } - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; diff --git a/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/__tests__/absence-detail-api.test.ts b/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/__tests__/absence-detail-api.test.ts index 3724a00..1c78892 100644 --- a/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/__tests__/absence-detail-api.test.ts +++ b/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/__tests__/absence-detail-api.test.ts @@ -392,7 +392,7 @@ describe("Agent Absence Detail API Routes", () => { const response = await PUT(event); const data = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(422); expect(data.error).toBe("Invalid date range"); }); diff --git a/src/routes/api/tenants/[id]/agents/[agentId]/absences/__tests__/absence-api.test.ts b/src/routes/api/tenants/[id]/agents/[agentId]/absences/__tests__/absence-api.test.ts index ffc1124..5e68acd 100644 --- a/src/routes/api/tenants/[id]/agents/[agentId]/absences/__tests__/absence-api.test.ts +++ b/src/routes/api/tenants/[id]/agents/[agentId]/absences/__tests__/absence-api.test.ts @@ -196,7 +196,7 @@ describe("Agent Absence API Routes", () => { const response = await POST(event); const data = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(422); expect(data.error).toBe("Invalid date range"); }); @@ -243,7 +243,7 @@ describe("Agent Absence API Routes", () => { const response = await POST(event); const data = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(422); expect(data.error).toBe("Missing tenant or agent ID"); }); }); @@ -422,7 +422,7 @@ describe("Agent Absence API Routes", () => { const response = await GET(event); const data = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(422); expect(data.error).toBe("Invalid date format"); }); diff --git a/src/routes/api/tenants/[id]/agents/__tests__/agent-api.test.ts b/src/routes/api/tenants/[id]/agents/__tests__/agent-api.test.ts index eabe044..2186e1e 100644 --- a/src/routes/api/tenants/[id]/agents/__tests__/agent-api.test.ts +++ b/src/routes/api/tenants/[id]/agents/__tests__/agent-api.test.ts @@ -134,19 +134,16 @@ describe("Agent API Routes", () => { expect(data.agents).toEqual(mockAgents); }); - it("should reject unauthenticated requests", async () => { - const event = createMockRequestEvent({ - locals: { user: null } as any, - }); - + it("should return 401 for unauthenticated requests", async () => { + const event = createMockRequestEvent({ locals: { user: null } as any }); const response = await GET(event); - const data = await response.json(); + const result = await response.json(); expect(response.status).toBe(401); - expect(data.error).toBe("Authentication required"); + expect(result.error).toBe("Authentication required"); }); - it("should reject insufficient permissions", async () => { + it("should return 403 for insufficient permissions", async () => { const event = createMockRequestEvent({ locals: { user: { @@ -156,12 +153,11 @@ describe("Agent API Routes", () => { } as any, }, }); - const response = await GET(event); - const data = await response.json(); + const result = await response.json(); expect(response.status).toBe(403); - expect(data.error).toBe("Insufficient permissions"); + expect(result.error).toBe("Insufficient permissions"); }); it("should handle missing tenant ID", async () => { @@ -172,7 +168,7 @@ describe("Agent API Routes", () => { const response = await GET(event); const data = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(422); expect(data.error).toBe("No tenant id given"); }); @@ -251,7 +247,7 @@ describe("Agent API Routes", () => { expect(data.agent).toEqual(mockAgent); }); - it("should reject staff users from creating agents", async () => { + it("should return 403 for staff users creating agents", async () => { const event = createMockRequestEvent({ locals: { user: { @@ -261,25 +257,21 @@ describe("Agent API Routes", () => { } as any, }, }); - const response = await POST(event); - const data = await response.json(); + const result = await response.json(); expect(response.status).toBe(403); - expect(data.error).toBe("Insufficient permissions"); + expect(result.error).toBe("Insufficient permissions"); expect(mockAgentService.createAgent).not.toHaveBeenCalled(); }); - it("should reject unauthenticated requests", async () => { - const event = createMockRequestEvent({ - locals: { user: null } as any, - }); - + it("should return 401 for unauthenticated requests", async () => { + const event = createMockRequestEvent({ locals: { user: null } as any }); const response = await POST(event); - const data = await response.json(); + const result = await response.json(); expect(response.status).toBe(401); - expect(data.error).toBe("Authentication required"); + expect(result.error).toBe("Authentication required"); }); it("should handle validation errors", async () => { @@ -289,7 +281,7 @@ describe("Agent API Routes", () => { const response = await POST(event); const data = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(422); expect(data.error).toBe("Invalid agent name"); }); @@ -323,7 +315,7 @@ describe("Agent API Routes", () => { const response = await POST(event); const data = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(422); expect(data.error).toBe("No tenant id given"); }); }); diff --git a/src/routes/api/tenants/[id]/appointments/+server.ts b/src/routes/api/tenants/[id]/appointments/+server.ts index f9da3c1..cc4f333 100644 --- a/src/routes/api/tenants/[id]/appointments/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/+server.ts @@ -1,10 +1,17 @@ import { json } from "@sveltejs/kit"; import { AppointmentService } from "$lib/server/services/appointment-service"; -import { ValidationError, NotFoundError } from "$lib/server/utils/errors"; +import { + BackendError, + ConflictError, + InternalError, + logError, + ValidationError, +} from "$lib/server/utils/errors"; import type { RequestHandler } from "@sveltejs/kit"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; import { checkPermission } from "$lib/server/utils/permissions"; +import { ERRORS } from "$lib/errors"; // Register OpenAPI documentation for POST registerOpenAPIRoute("/tenants/{id}/appointments", "POST", { @@ -326,13 +333,10 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { const tenantId = params.id; if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); + throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID); } - const error = checkPermission(locals, tenantId); - if (error) { - return error; - } + checkPermission(locals, tenantId); const body = await request.json(); @@ -360,25 +364,19 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { { status: 201 }, ); } catch (error) { - log.error("Error creating appointment:", JSON.stringify(error || "?")); - - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); + logError(log)("Error creating appointment", error, locals.user?.userId, params.id); + if (error instanceof BackendError) { + return error.toJson(); } - if (error instanceof NotFoundError) { - return json({ error: error.message }, { status: 404 }); - } - - // ConflictError from AppointmentService if ( (error instanceof Error && error.message.includes("conflict")) || (error instanceof Error && error.message.includes("paused")) ) { - return json({ error: error.message }, { status: 409 }); + return new ConflictError(error.message).toJson(); } - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; @@ -389,13 +387,10 @@ export const GET: RequestHandler = async ({ params, url, locals }) => { const tenantId = params.id; if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); + throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID); } - const error = checkPermission(locals, tenantId); - if (error) { - return error; - } + checkPermission(locals, tenantId); // Extract query parameters const startDate = url.searchParams.get("startDate"); @@ -405,7 +400,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => { const status = url.searchParams.get("status"); if (!startDate || !endDate) { - return json({ error: "startDate and endDate are required" }, { status: 400 }); + throw new ValidationError("startDate and endDate are required"); } const query = { @@ -435,16 +430,11 @@ export const GET: RequestHandler = async ({ params, url, locals }) => { appointments, }); } catch (error) { - log.error("Error getting appointments:", JSON.stringify(error || "?")); - - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); + logError(log)("Error getting appointments", error, locals.user?.userId, params.id); + if (error instanceof BackendError) { + return error.toJson(); } - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); - } - - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts index d93cb99..781e264 100644 --- a/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts @@ -1,6 +1,12 @@ import { json } from "@sveltejs/kit"; import { AppointmentService } from "$lib/server/services/appointment-service"; -import { NotFoundError } from "$lib/server/utils/errors"; +import { + BackendError, + InternalError, + logError, + NotFoundError, + ValidationError, +} from "$lib/server/utils/errors"; import type { RequestHandler } from "@sveltejs/kit"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; @@ -204,13 +210,10 @@ export const GET: RequestHandler = async ({ params, locals }) => { const appointmentId = params.appointmentId; if (!tenantId || !appointmentId) { - return json({ error: "Tenant ID and appointment ID are required" }, { status: 400 }); + throw new ValidationError("Tenant ID and appointment ID are required"); } - const error = checkPermission(locals, tenantId); - if (error) { - return error; - } + checkPermission(locals, tenantId); log.debug("Getting appointment by ID", { tenantId, @@ -222,7 +225,7 @@ export const GET: RequestHandler = async ({ params, locals }) => { const appointment = await appointmentService.getAppointmentById(appointmentId); if (!appointment) { - return json({ error: "Appointment not found" }, { status: 404 }); + throw new NotFoundError("Appointment not found"); } log.debug("Appointment retrieved successfully", { @@ -235,13 +238,13 @@ export const GET: RequestHandler = async ({ params, locals }) => { appointment, }); } catch (error) { - log.error("Error getting appointment:", JSON.stringify(error || "?")); + logError(log)("Error getting appointment", error, locals.user?.userId, params.id); - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); + if (error instanceof BackendError) { + return error.toJson(); } - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; @@ -253,13 +256,10 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { const appointmentId = params.appointmentId; if (!tenantId || !appointmentId) { - return json({ error: "Tenant ID and appointment ID are required" }, { status: 400 }); + throw new ValidationError("Tenant ID and appointment ID are required"); } - const error = checkPermission(locals, tenantId, true); - if (error) { - return error; - } + checkPermission(locals, tenantId, true); log.debug("Deleting appointment", { tenantId, @@ -284,12 +284,12 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { message: "Appointment deleted successfully", }); } catch (error) { - log.error("Error deleting appointment:", JSON.stringify(error || "?")); + logError(log)("Error deleting appointment", error, locals.user?.userId, params.id); - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); + if (error instanceof BackendError) { + return error.toJson(); } - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/__tests__/appointment-detail-api.test.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/__tests__/appointment-detail-api.test.ts index 50c64cd..ec2bb3b 100644 --- a/src/routes/api/tenants/[id]/appointments/[appointmentId]/__tests__/appointment-detail-api.test.ts +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/__tests__/appointment-detail-api.test.ts @@ -77,7 +77,6 @@ describe("Appointment Detail API Routes", () => { it("should return 404 if appointment not found", async () => { mockAppointmentService.getAppointmentById.mockResolvedValue(null); - const event = createMockRequestEvent(); const response = await GET(event); const result = await response.json(); @@ -87,10 +86,7 @@ describe("Appointment Detail API Routes", () => { }); it("should return 401 if user is not authenticated", async () => { - const event = createMockRequestEvent({ - locals: {}, - }); - + const event = createMockRequestEvent({ locals: {} }); const response = await GET(event); const result = await response.json(); @@ -109,7 +105,6 @@ describe("Appointment Detail API Routes", () => { } as any, }, }); - const response = await GET(event); const result = await response.json(); @@ -265,7 +260,7 @@ describe("Appointment Detail API Routes", () => { expect(response.status).toBe(200); }); - it("should return 400 if tenant ID or appointment ID is missing", async () => { + it("should return 422 if tenant ID or appointment ID is missing", async () => { const event = createMockRequestEvent({ params: { id: mockTenantId }, }); @@ -273,7 +268,7 @@ describe("Appointment Detail API Routes", () => { const response = await DELETE(event); const result = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(422); expect(result.error).toBe("Tenant ID and appointment ID are required"); }); diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/cancel/+server.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/cancel/+server.ts index 025e96e..0a00fb0 100644 --- a/src/routes/api/tenants/[id]/appointments/[appointmentId]/cancel/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/cancel/+server.ts @@ -1,6 +1,6 @@ import { json } from "@sveltejs/kit"; import { AppointmentService } from "$lib/server/services/appointment-service"; -import { NotFoundError } from "$lib/server/utils/errors"; +import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors"; import type { RequestHandler } from "@sveltejs/kit"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; @@ -116,13 +116,10 @@ export const PUT: RequestHandler = async ({ params, locals }) => { // Check if user is authenticated if (!tenantId || !appointmentId) { - return json({ error: "Tenant ID and appointment ID are required" }, { status: 400 }); + throw new ValidationError("Tenant ID and appointment ID are required"); } - const error = checkPermission(locals, tenantId); - if (error) { - return error; - } + checkPermission(locals, tenantId); log.debug("Cancelling appointment", { tenantId, @@ -144,12 +141,12 @@ export const PUT: RequestHandler = async ({ params, locals }) => { appointment: cancelledAppointment, }); } catch (error) { - log.error("Error cancelling appointment:", JSON.stringify(error || "?")); + logError(log)("Error cancelling appointment", error, locals.user?.userId, params.id); - if (error instanceof NotFoundError) { - return json({ error: error.message }, { status: 404 }); + if (error instanceof BackendError) { + return error.toJson(); } - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/cancel/__tests__/appointment-cancel-api.test.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/cancel/__tests__/appointment-cancel-api.test.ts index 204105d..fe0cc63 100644 --- a/src/routes/api/tenants/[id]/appointments/[appointmentId]/cancel/__tests__/appointment-cancel-api.test.ts +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/cancel/__tests__/appointment-cancel-api.test.ts @@ -74,10 +74,7 @@ describe("Appointment Cancel API", () => { }); it("should return 401 if user is not authenticated", async () => { - const event = createMockRequestEvent({ - locals: {}, - }); - + const event = createMockRequestEvent({ locals: {} }); const response = await PUT(event); const result = await response.json(); @@ -96,7 +93,6 @@ describe("Appointment Cancel API", () => { } as any, }, }); - const response = await PUT(event); const result = await response.json(); @@ -150,7 +146,7 @@ describe("Appointment Cancel API", () => { expect(response.status).toBe(200); }); - it("should return 400 if tenant ID or appointment ID is missing", async () => { + it("should return 422 if tenant ID or appointment ID is missing", async () => { const event = createMockRequestEvent({ params: { id: mockTenantId }, }); @@ -158,7 +154,7 @@ describe("Appointment Cancel API", () => { const response = await PUT(event); const result = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(422); expect(result.error).toBe("Tenant ID and appointment ID are required"); }); diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts index ad75fbf..29bbf53 100644 --- a/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts @@ -1,6 +1,6 @@ import { json } from "@sveltejs/kit"; import { AppointmentService } from "$lib/server/services/appointment-service"; -import { NotFoundError } from "$lib/server/utils/errors"; +import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors"; import type { RequestHandler } from "@sveltejs/kit"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; @@ -115,13 +115,10 @@ export const PUT: RequestHandler = async ({ params, locals }) => { const appointmentId = params.appointmentId; if (!tenantId || !appointmentId) { - return json({ error: "Tenant ID and appointment ID are required" }, { status: 400 }); + throw new ValidationError("Tenant ID and appointment ID are required"); } - const error = checkPermission(locals, tenantId); - if (error) { - return error; - } + checkPermission(locals, tenantId); log.debug("Confirming appointment", { tenantId, @@ -143,12 +140,10 @@ export const PUT: RequestHandler = async ({ params, locals }) => { appointment: confirmedAppointment, }); } catch (error) { - log.error("Error confirming appointment:", JSON.stringify(error || "?")); - - if (error instanceof NotFoundError) { - return json({ error: error.message }, { status: 404 }); + logError(log)("Error confirming appointment", error, locals.user?.userId, params.id); + if (error instanceof BackendError) { + return error.toJson(); } - - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/__tests__/appointment-confirm-api.test.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/__tests__/appointment-confirm-api.test.ts index 9b1b7c2..86f1e84 100644 --- a/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/__tests__/appointment-confirm-api.test.ts +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/__tests__/appointment-confirm-api.test.ts @@ -74,10 +74,7 @@ describe("Appointment Confirm API", () => { }); it("should return 401 if user is not authenticated", async () => { - const event = createMockRequestEvent({ - locals: {}, - }); - + const event = createMockRequestEvent({ locals: {} }); const response = await PUT(event); const result = await response.json(); @@ -96,7 +93,6 @@ describe("Appointment Confirm API", () => { } as any, }, }); - const response = await PUT(event); const result = await response.json(); @@ -150,7 +146,7 @@ describe("Appointment Confirm API", () => { expect(response.status).toBe(200); }); - it("should return 400 if tenant ID or appointment ID is missing", async () => { + it("should return 422 if tenant ID or appointment ID is missing", async () => { const event = createMockRequestEvent({ params: { id: mockTenantId }, }); @@ -158,7 +154,7 @@ describe("Appointment Confirm API", () => { const response = await PUT(event); const result = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(422); expect(result.error).toBe("Tenant ID and appointment ID are required"); }); diff --git a/src/routes/api/tenants/[id]/appointments/__tests__/appointment-api.test.ts b/src/routes/api/tenants/[id]/appointments/__tests__/appointment-api.test.ts index 534459e..4e67a99 100644 --- a/src/routes/api/tenants/[id]/appointments/__tests__/appointment-api.test.ts +++ b/src/routes/api/tenants/[id]/appointments/__tests__/appointment-api.test.ts @@ -92,10 +92,7 @@ describe("Appointment API Routes", () => { }); it("should return 401 if user is not authenticated", async () => { - const event = createMockRequestEvent({ - locals: {}, - }); - + const event = createMockRequestEvent({ locals: {} }); const response = await POST(event); const result = await response.json(); @@ -114,7 +111,6 @@ describe("Appointment API Routes", () => { } as any, }, }); - const response = await POST(event); const result = await response.json(); @@ -178,7 +174,7 @@ describe("Appointment API Routes", () => { expect(response.status).toBe(201); }); - it("should return 400 for validation errors", async () => { + it("should return 422 for validation errors", async () => { mockAppointmentService.createAppointment.mockRejectedValue( new ValidationError("Invalid data"), ); @@ -187,7 +183,7 @@ describe("Appointment API Routes", () => { const response = await POST(event); const result = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(422); expect(result.error).toBe("Invalid data"); }); @@ -277,7 +273,7 @@ describe("Appointment API Routes", () => { expect(result.error).toBe("Insufficient permissions"); }); - it("should return 400 if startDate or endDate is missing", async () => { + it("should return 422 if startDate or endDate is missing", async () => { const event = createMockRequestEvent({ url: new URL("http://localhost?startDate=2024-12-01T00:00:00Z"), }); @@ -285,7 +281,7 @@ describe("Appointment API Routes", () => { const response = await GET(event); const result = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(422); expect(result.error).toBe("startDate and endDate are required"); }); @@ -310,7 +306,7 @@ describe("Appointment API Routes", () => { }); }); - it("should return 400 for validation errors", async () => { + it("should return 422 for validation errors", async () => { mockAppointmentService.queryAppointments.mockRejectedValue( new ValidationError("Invalid query"), ); @@ -319,7 +315,7 @@ describe("Appointment API Routes", () => { const response = await GET(event); const result = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(422); expect(result.error).toBe("Invalid query"); }); }); diff --git a/src/routes/api/tenants/[id]/channels/+server.ts b/src/routes/api/tenants/[id]/channels/+server.ts index 5223383..af29374 100644 --- a/src/routes/api/tenants/[id]/channels/+server.ts +++ b/src/routes/api/tenants/[id]/channels/+server.ts @@ -1,10 +1,11 @@ import { json } from "@sveltejs/kit"; import { ChannelService } from "$lib/server/services/channel-service"; -import { ValidationError, NotFoundError } from "$lib/server/utils/errors"; +import { ValidationError, logError, BackendError, InternalError } from "$lib/server/utils/errors"; import type { RequestHandler } from "@sveltejs/kit"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; import { checkPermission } from "$lib/server/utils/permissions"; +import { ERRORS } from "$lib/errors"; // Register OpenAPI documentation for POST registerOpenAPIRoute("/tenants/{id}/channels", "POST", { @@ -321,13 +322,10 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { const tenantId = params.id; if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); + throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID); } - const error = checkPermission(locals, tenantId, true); - if (error) { - return error; - } + checkPermission(locals, tenantId, true); const body = await request.json(); @@ -355,17 +353,13 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { { status: 201 }, ); } catch (error) { - log.error("Error creating channel:", JSON.stringify(error || "?")); + logError(log)("Error creating channel", error, locals.user?.userId, params.id); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); + if (error instanceof BackendError) { + return error.toJson(); } - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); - } - - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; @@ -376,13 +370,10 @@ export const GET: RequestHandler = async ({ params, locals }) => { const tenantId = params.id; if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); + throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID); } - const error = checkPermission(locals, tenantId, true); - if (error) { - return error; - } + checkPermission(locals, tenantId, true); log.debug("Getting all channels", { tenantId, @@ -402,12 +393,11 @@ export const GET: RequestHandler = async ({ params, locals }) => { channels, }); } catch (error) { - log.error("Error getting channels:", JSON.stringify(error || "?")); + logError(log)("Error in getting channels", error, locals.user?.userId, params.id); - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); + if (error instanceof BackendError) { + return error.toJson(); } - - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; diff --git a/src/routes/api/tenants/[id]/channels/[channelId]/+server.ts b/src/routes/api/tenants/[id]/channels/[channelId]/+server.ts index 5261554..0141887 100644 --- a/src/routes/api/tenants/[id]/channels/[channelId]/+server.ts +++ b/src/routes/api/tenants/[id]/channels/[channelId]/+server.ts @@ -1,10 +1,17 @@ import { json } from "@sveltejs/kit"; import { ChannelService } from "$lib/server/services/channel-service"; -import { ValidationError, NotFoundError } from "$lib/server/utils/errors"; +import { + ValidationError, + NotFoundError, + BackendError, + InternalError, + logError, +} from "$lib/server/utils/errors"; import type { RequestHandler } from "@sveltejs/kit"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; import { checkPermission } from "$lib/server/utils/permissions"; +import { ERRORS } from "$lib/errors"; // Register OpenAPI documentation for GET registerOpenAPIRoute("/tenants/{id}/channels/{channelId}", "GET", { @@ -405,13 +412,10 @@ export const GET: RequestHandler = async ({ params, locals }) => { const channelId = params.channelId; if (!tenantId || !channelId) { - return json({ error: "Missing tenant or channel ID" }, { status: 400 }); + throw new ValidationError("Missing tenant or channel ID"); } - const error = checkPermission(locals, tenantId, true); - if (error) { - return error; - } + checkPermission(locals, tenantId, true); log.debug("Getting channel details", { tenantId, @@ -423,7 +427,7 @@ export const GET: RequestHandler = async ({ params, locals }) => { const channel = await channelService.getChannelById(channelId); if (!channel) { - return json({ error: "Channel not found" }, { status: 404 }); + throw new NotFoundError(ERRORS.CHANNELS.NOT_FOUND); } log.debug("Channel details retrieved successfully", { @@ -436,13 +440,12 @@ export const GET: RequestHandler = async ({ params, locals }) => { channel, }); } catch (error) { - log.error("Error getting channel details:", JSON.stringify(error || "?")); + logError(log)("Error getting channel details", error, locals.user?.userId, params.id); - if (error instanceof NotFoundError) { - return json({ error: "Channel not found" }, { status: 404 }); + if (error instanceof BackendError) { + return error.toJson(); } - - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; @@ -454,13 +457,10 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { const channelId = params.channelId; if (!tenantId || !channelId) { - return json({ error: "Missing tenant or channel ID" }, { status: 400 }); + throw new ValidationError("Missing tenant or channel ID"); } - const error = checkPermission(locals, tenantId, true); - if (error) { - return error; - } + checkPermission(locals, tenantId, true); const body = await request.json(); @@ -485,17 +485,13 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { channel: updatedChannel, }); } catch (error) { - log.error("Error updating channel:", JSON.stringify(error || "?")); + logError(log)("Error updating channel", error, locals.user?.userId, params.id); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); + if (error instanceof BackendError) { + return error.toJson(); } - if (error instanceof NotFoundError) { - return json({ error: "Channel not found" }, { status: 404 }); - } - - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; @@ -507,13 +503,10 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { const channelId = params.channelId; if (!tenantId || !channelId) { - return json({ error: "Missing tenant or channel ID" }, { status: 400 }); + throw new ValidationError("Missing tenant or channel ID"); } - const error = checkPermission(locals, tenantId, true); - if (error) { - return error; - } + checkPermission(locals, tenantId, true); log.debug("Deleting channel", { tenantId, @@ -525,7 +518,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { const deleted = await channelService.deleteChannel(channelId); if (!deleted) { - return json({ error: "Channel not found" }, { status: 404 }); + throw new NotFoundError(ERRORS.CHANNELS.NOT_FOUND); } log.debug("Channel deleted successfully", { @@ -538,12 +531,12 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { message: "Channel deleted successfully", }); } catch (error) { - log.error("Error deleting channel:", JSON.stringify(error || "?")); + logError(log)("Error deleting channel", error, locals.user?.userId, params.id); - if (error instanceof NotFoundError) { - return json({ error: "Channel not found" }, { status: 404 }); + if (error instanceof BackendError) { + return error.toJson(); } - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; diff --git a/src/routes/api/tenants/[id]/config/+server.ts b/src/routes/api/tenants/[id]/config/+server.ts index 5f5ee1b..c560f09 100644 --- a/src/routes/api/tenants/[id]/config/+server.ts +++ b/src/routes/api/tenants/[id]/config/+server.ts @@ -1,10 +1,11 @@ import { json } from "@sveltejs/kit"; import { TenantAdminService } from "$lib/server/services/tenant-admin-service"; -import { ValidationError, NotFoundError } from "$lib/server/utils/errors"; +import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors"; import type { RequestHandler } from "@sveltejs/kit"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; import { checkPermission } from "$lib/server/utils/permissions"; +import { ERRORS } from "$lib/errors"; // Register OpenAPI documentation for GET registerOpenAPIRoute("/tenants/{id}/config", "GET", { @@ -185,13 +186,10 @@ export const GET: RequestHandler = async ({ locals, params }) => { log.debug("Getting tenant configuration", { tenantId }); if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); + throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID); } - const error = checkPermission(locals, tenantId, true); - if (error) { - return error; - } + checkPermission(locals, tenantId, true); const tenantService = await TenantAdminService.getTenantById(tenantId); const config = await tenantService.configuration; @@ -203,13 +201,13 @@ export const GET: RequestHandler = async ({ locals, params }) => { return json(config); } catch (error) { - log.error("Error getting tenant configuration:", JSON.stringify(error || "?")); + logError(log)("Error getting tenant configuration", error, locals.user?.userId, params.id); - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); + if (error instanceof BackendError) { + return error.toJson(); } - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; @@ -226,13 +224,10 @@ export const PUT: RequestHandler = async ({ locals, params, request }) => { }); if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); + throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID); } - const error = checkPermission(locals, tenantId, true); - if (error) { - return error; - } + checkPermission(locals, tenantId, true); const tenantService = await TenantAdminService.getTenantById(tenantId); await tenantService.updateTenantConfig(body); @@ -247,16 +242,12 @@ export const PUT: RequestHandler = async ({ locals, params, request }) => { updatedKeys: Object.keys(body), }); } catch (error) { - log.error("Error updating tenant configuration:", JSON.stringify(error || "?")); + logError(log)("Error updating tenant configuration", error, locals.user?.userId, params.id); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); + if (error instanceof BackendError) { + return error.toJson(); } - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); - } - - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; diff --git a/src/routes/api/tenants/[id]/setup-state/+server.ts b/src/routes/api/tenants/[id]/setup-state/+server.ts index becb7d0..852f613 100644 --- a/src/routes/api/tenants/[id]/setup-state/+server.ts +++ b/src/routes/api/tenants/[id]/setup-state/+server.ts @@ -1,11 +1,12 @@ import { json } from "@sveltejs/kit"; import { TenantAdminService } from "$lib/server/services/tenant-admin-service"; -import { ValidationError, NotFoundError } from "$lib/server/utils/errors"; +import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors"; import type { RequestHandler } from "@sveltejs/kit"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; import z from "zod/v4"; import { checkPermission } from "$lib/server/utils/permissions"; +import { ERRORS } from "$lib/errors"; const setupStateSchema = z.object({ setupState: z.enum(["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"]), @@ -114,13 +115,10 @@ export const PUT: RequestHandler = async ({ locals, params, request }) => { }); if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); + throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID); } - const error = checkPermission(locals, tenantId, true); - if (error) { - return error; - } + checkPermission(locals, tenantId, true); const validation = setupStateSchema.safeParse(body); if (!validation.success) { @@ -140,16 +138,12 @@ export const PUT: RequestHandler = async ({ locals, params, request }) => { tenant: updatedTenant, }); } catch (error) { - log.error("Error updating tenant setup state:", JSON.stringify(error || "?")); + logError(log)("Error updating tenant setup state", error, locals.user?.userId, params.id); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); + if (error instanceof BackendError) { + return error.toJson(); } - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); - } - - return json({ error: "Internal server error" }, { status: 500 }); + return new InternalError().toJson(); } }; diff --git a/src/routes/api/tenants/tenant-routes.test.ts b/src/routes/api/tenants/tenant-routes.test.ts index 4c214f3..eddd2ba 100644 --- a/src/routes/api/tenants/tenant-routes.test.ts +++ b/src/routes/api/tenants/tenant-routes.test.ts @@ -43,21 +43,6 @@ vi.mock("$lib/logger", () => ({ })), })); -vi.mock("$lib/server/utils/errors", () => ({ - ValidationError: class ValidationError extends Error { - constructor(message: string) { - super(message); - this.name = "ValidationError"; - } - }, - NotFoundError: class NotFoundError extends Error { - constructor(message: string) { - super(message); - this.name = "NotFoundError"; - } - }, -})); - vi.mock("$lib/server/auth/authorization-service", () => ({ AuthorizationService: { requireGlobalAdmin: vi.fn(), @@ -154,10 +139,8 @@ describe("Tenant API Routes", () => { } as any); const data = await response.json(); - expect(data).toEqual({ - error: "No tenant id given", - }); - expect(response.status).toBe(400); + expect(response.status).toBe(422); + expect(data.error).toBe("No tenant id given"); }); }); @@ -210,10 +193,8 @@ describe("Tenant API Routes", () => { } as any); const data = await response.json(); - expect(data).toEqual({ - error: "No tenant id given", - }); - expect(response.status).toBe(400); + expect(response.status).toBe(422); + expect(data.error).toBe("No tenant id given"); }); }); @@ -295,10 +276,8 @@ describe("Tenant API Routes", () => { } as any); const data = await response.json(); - expect(data).toEqual({ - error: "Invalid configuration data", - }); - expect(response.status).toBe(400); + expect(response.status).toBe(422); + expect(data.error).toBe("Invalid configuration data"); }); it("should handle tenant not found", async () => { @@ -330,10 +309,8 @@ describe("Tenant API Routes", () => { } as any); const data = await response.json(); - expect(data).toEqual({ - error: "Tenant not found", - }); expect(response.status).toBe(404); + expect(data.error).toBe("Tenant not found"); }); }); }); diff --git a/src/routes/api/tenants/tenants.test.ts b/src/routes/api/tenants/tenants.test.ts index 08ae348..2685f74 100644 --- a/src/routes/api/tenants/tenants.test.ts +++ b/src/routes/api/tenants/tenants.test.ts @@ -184,9 +184,7 @@ describe("/api/tenants", () => { } as any); const data = await response.json(); - expect(data).toEqual({ - message: "Invalid tenant creation request", - }); + expect(data.error).toEqual("Invalid tenant creation request"); expect(response.status).toBe(422); }); @@ -226,9 +224,7 @@ describe("/api/tenants", () => { } as any); const data = await response.json(); - expect(data).toEqual({ - error: ERRORS.TENANTS.NAME_EXISTS, - }); + expect(data.error).toEqual(ERRORS.TENANTS.NAME_EXISTS); expect(response.status).toBe(409); }); @@ -268,9 +264,7 @@ describe("/api/tenants", () => { } as any); const data = await response.json(); - expect(data).toEqual({ - error: "Internal server error", - }); + expect(data.error).toEqual("Internal server error"); expect(response.status).toBe(500); }); }); @@ -339,9 +333,7 @@ describe("/api/tenants", () => { } as any); const data = await response.json(); - expect(data).toEqual({ - error: "Internal server error", - }); + expect(data.error).toEqual("Internal server error"); expect(response.status).toBe(500); }); }); diff --git a/tenant-migrations/0001_equal_king_bedlam.sql b/tenant-migrations/0001_equal_king_bedlam.sql new file mode 100644 index 0000000..ba49005 --- /dev/null +++ b/tenant-migrations/0001_equal_king_bedlam.sql @@ -0,0 +1,23 @@ +CREATE TABLE "agent_absence" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "agent_id" uuid NOT NULL, + "start_date" timestamp NOT NULL, + "end_date" timestamp NOT NULL, + "absence_type" text DEFAULT '' NOT NULL, + "description" text +); +--> statement-breakpoint +DROP TABLE "staff" CASCADE;--> statement-breakpoint +ALTER TABLE "agent" RENAME COLUMN "logo" TO "image";--> statement-breakpoint +ALTER TABLE "appointment" RENAME COLUMN "title" TO "name";--> statement-breakpoint +ALTER TABLE "appointment" RENAME COLUMN "description" TO "phone";--> statement-breakpoint +ALTER TABLE "channel" ADD COLUMN "names" json NOT NULL;--> statement-breakpoint +ALTER TABLE "channel" ADD COLUMN "paused" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "channel" ADD COLUMN "descriptions" json;--> statement-breakpoint +ALTER TABLE "channel" ADD COLUMN "languages" json NOT NULL;--> statement-breakpoint +ALTER TABLE "agent_absence" ADD CONSTRAINT "agent_absence_agent_id_agent_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agent"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "channel" DROP COLUMN "name";--> statement-breakpoint +ALTER TABLE "channel" DROP COLUMN "description";--> statement-breakpoint +ALTER TABLE "channel" DROP COLUMN "language";--> statement-breakpoint +ALTER TABLE "slotTemplate" DROP COLUMN "name";--> statement-breakpoint +DROP TYPE "public"."channel_type"; \ No newline at end of file diff --git a/tenant-migrations/meta/0001_snapshot.json b/tenant-migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..3b26d5e --- /dev/null +++ b/tenant-migrations/meta/0001_snapshot.json @@ -0,0 +1,458 @@ +{ + "id": "8afcc6a0-a00f-4f73-adb4-8c73b4b66986", + "prevId": "ae9961fd-c495-4680-98ed-595599978298", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent": { + "name": "agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "bytea", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_absence": { + "name": "agent_absence", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "absence_type": { + "name": "absence_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_absence_agent_id_agent_id_fk": { + "name": "agent_absence_agent_id_agent_id_fk", + "tableFrom": "agent_absence", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment": { + "name": "appointment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "appointment_date": { + "name": "appointment_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "appointment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'NEW'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "appointment_client_id_client_id_fk": { + "name": "appointment_client_id_client_id_fk", + "tableFrom": "appointment", + "tableTo": "client", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "appointment_channel_id_channel_id_fk": { + "name": "appointment_channel_id_channel_id_fk", + "tableFrom": "appointment", + "tableTo": "channel", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel": { + "name": "channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "names": { + "name": "names", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused": { + "name": "paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "languages": { + "name": "languages", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "requires_confirmation": { + "name": "requires_confirmation", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agent": { + "name": "channel_agent", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agent_channel_id_channel_id_fk": { + "name": "channel_agent_channel_id_channel_id_fk", + "tableFrom": "channel_agent", + "tableTo": "channel", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "channel_agent_agent_id_agent_id_fk": { + "name": "channel_agent_agent_id_agent_id_fk", + "tableFrom": "channel_agent", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_slot_template": { + "name": "channel_slot_template", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slot_template_id": { + "name": "slot_template_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "channel_slot_template_channel_id_channel_id_fk": { + "name": "channel_slot_template_channel_id_channel_id_fk", + "tableFrom": "channel_slot_template", + "tableTo": "channel", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "channel_slot_template_slot_template_id_slotTemplate_id_fk": { + "name": "channel_slot_template_slot_template_id_slotTemplate_id_fk", + "tableFrom": "channel_slot_template", + "tableTo": "slotTemplate", + "columnsFrom": ["slot_template_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client": { + "name": "client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "hash_key": { + "name": "hash_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_share": { + "name": "private_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "client_hash_key_unique": { + "name": "client_hash_key_unique", + "nullsNotDistinct": false, + "columns": ["hash_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slotTemplate": { + "name": "slotTemplate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "weekdays": { + "name": "weekdays", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "from": { + "name": "from", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "to": { + "name": "to", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.appointment_status": { + "name": "appointment_status", + "schema": "public", + "values": ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/tenant-migrations/meta/_journal.json b/tenant-migrations/meta/_journal.json index 6a15a06..0f2c282 100644 --- a/tenant-migrations/meta/_journal.json +++ b/tenant-migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1752671302514, "tag": "0000_neat_nemesis", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1758021994040, + "tag": "0001_equal_king_bedlam", + "breakpoints": true } ] }