@startsimpli/api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +329 -0
  2. package/package.json +42 -0
  3. package/src/__tests__/jwt-refresh.test.ts +195 -0
  4. package/src/__tests__/query-params.test.ts +144 -0
  5. package/src/__tests__/url-builder.test.ts +121 -0
  6. package/src/constants/endpoints.ts +39 -0
  7. package/src/index.ts +109 -0
  8. package/src/lib/api-client.ts +89 -0
  9. package/src/lib/contacts-api.ts +111 -0
  10. package/src/lib/cors.ts +122 -0
  11. package/src/lib/entities-api.ts +123 -0
  12. package/src/lib/env.ts +35 -0
  13. package/src/lib/error-handler.ts +138 -0
  14. package/src/lib/errors.ts +381 -0
  15. package/src/lib/fetch-wrapper.ts +188 -0
  16. package/src/lib/llm-sanitize.ts +145 -0
  17. package/src/lib/messages-api.ts +273 -0
  18. package/src/lib/messages-api.ts.backup +273 -0
  19. package/src/lib/organizations-api.ts +132 -0
  20. package/src/lib/rate-limit.ts +91 -0
  21. package/src/lib/sanitize.ts +39 -0
  22. package/src/lib/workflows-api.ts +159 -0
  23. package/src/middleware/index.ts +12 -0
  24. package/src/middleware/with-auth.ts +90 -0
  25. package/src/middleware/with-error-handling.ts +83 -0
  26. package/src/middleware/with-validation.ts +110 -0
  27. package/src/types/api.ts +38 -0
  28. package/src/types/contact.ts +49 -0
  29. package/src/types/entity.ts +153 -0
  30. package/src/types/error.ts +129 -0
  31. package/src/types/funnel.ts +133 -0
  32. package/src/types/index.ts +95 -0
  33. package/src/types/organization.ts +49 -0
  34. package/src/types/response.ts +44 -0
  35. package/src/types/workflow.ts +69 -0
  36. package/src/utils/index.ts +13 -0
  37. package/src/utils/query-params.ts +79 -0
  38. package/src/utils/url-builder.ts +78 -0
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Core entities API wrapper for /api/v1/core/
3
+ */
4
+
5
+ import type {
6
+ Tag,
7
+ EntityTag,
8
+ Metric,
9
+ Profile,
10
+ Attribute,
11
+ Relationship,
12
+ PaginatedResponse,
13
+ PaginationParams,
14
+ } from '../types';
15
+ import { mergeQueryParams } from '../utils';
16
+ import { ENDPOINTS } from '../constants/endpoints';
17
+ import type { ApiClient } from './api-client';
18
+
19
+ export class EntitiesApi {
20
+ constructor(private client: ApiClient) {}
21
+
22
+ /**
23
+ * Tags
24
+ */
25
+ async listTags(pagination?: PaginationParams): Promise<PaginatedResponse<Tag>> {
26
+ const params = mergeQueryParams(pagination);
27
+ return this.client.fetch.get<PaginatedResponse<Tag>>(ENDPOINTS.TAGS, { params });
28
+ }
29
+
30
+ async getTag(id: number): Promise<Tag> {
31
+ return this.client.fetch.get<Tag>(ENDPOINTS.TAG(String(id)));
32
+ }
33
+
34
+ async createTag(data: Partial<Tag>): Promise<Tag> {
35
+ return this.client.fetch.post<Tag>(ENDPOINTS.TAGS, data);
36
+ }
37
+
38
+ /**
39
+ * Entity Tags
40
+ */
41
+ async listEntityTags(
42
+ pagination?: PaginationParams,
43
+ filters?: { entityId?: string; tagId?: number; category?: string }
44
+ ): Promise<PaginatedResponse<EntityTag>> {
45
+ const params = mergeQueryParams(pagination, undefined, filters);
46
+ return this.client.fetch.get<PaginatedResponse<EntityTag>>(ENDPOINTS.ENTITY_TAGS, {
47
+ params,
48
+ });
49
+ }
50
+
51
+ async getEntityTag(id: number): Promise<EntityTag> {
52
+ return this.client.fetch.get<EntityTag>(ENDPOINTS.ENTITY_TAG(String(id)));
53
+ }
54
+
55
+ /**
56
+ * Metrics
57
+ */
58
+ async listMetrics(
59
+ pagination?: PaginationParams,
60
+ filters?: { entityId?: string; type?: string; subtype?: string }
61
+ ): Promise<PaginatedResponse<Metric>> {
62
+ const params = mergeQueryParams(pagination, undefined, filters);
63
+ return this.client.fetch.get<PaginatedResponse<Metric>>(ENDPOINTS.METRICS, { params });
64
+ }
65
+
66
+ async getMetric(id: number): Promise<Metric> {
67
+ return this.client.fetch.get<Metric>(ENDPOINTS.METRIC(String(id)));
68
+ }
69
+
70
+ /**
71
+ * Profiles
72
+ */
73
+ async listProfiles(
74
+ pagination?: PaginationParams,
75
+ filters?: { entityId?: string; type?: string; subtype?: string }
76
+ ): Promise<PaginatedResponse<Profile>> {
77
+ const params = mergeQueryParams(pagination, undefined, filters);
78
+ return this.client.fetch.get<PaginatedResponse<Profile>>(ENDPOINTS.PROFILES, { params });
79
+ }
80
+
81
+ async getProfile(id: number): Promise<Profile> {
82
+ return this.client.fetch.get<Profile>(ENDPOINTS.PROFILE(String(id)));
83
+ }
84
+
85
+ /**
86
+ * Attributes
87
+ */
88
+ async listAttributes(
89
+ pagination?: PaginationParams,
90
+ filters?: { entityId?: string; type?: string; subtype?: string; isCurrent?: boolean }
91
+ ): Promise<PaginatedResponse<Attribute>> {
92
+ const params = mergeQueryParams(pagination, undefined, filters);
93
+ return this.client.fetch.get<PaginatedResponse<Attribute>>(ENDPOINTS.ATTRIBUTES, {
94
+ params,
95
+ });
96
+ }
97
+
98
+ async getAttribute(id: number): Promise<Attribute> {
99
+ return this.client.fetch.get<Attribute>(ENDPOINTS.ATTRIBUTE(String(id)));
100
+ }
101
+
102
+ /**
103
+ * Relationships
104
+ */
105
+ async listRelationships(
106
+ pagination?: PaginationParams,
107
+ filters?: {
108
+ fromEntityId?: string;
109
+ toEntityId?: string;
110
+ type?: string;
111
+ isCurrent?: boolean;
112
+ }
113
+ ): Promise<PaginatedResponse<Relationship>> {
114
+ const params = mergeQueryParams(pagination, undefined, filters);
115
+ return this.client.fetch.get<PaginatedResponse<Relationship>>(ENDPOINTS.RELATIONSHIPS, {
116
+ params,
117
+ });
118
+ }
119
+
120
+ async getRelationship(id: number): Promise<Relationship> {
121
+ return this.client.fetch.get<Relationship>(ENDPOINTS.RELATIONSHIP(String(id)));
122
+ }
123
+ }
package/src/lib/env.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Environment variable utilities for server-side use.
3
+ *
4
+ * These are generic helpers for accessing process.env safely.
5
+ * App-specific schema validation (via zod) lives in each app's src/lib/env.ts.
6
+ */
7
+
8
+ /**
9
+ * Returns the value of an environment variable, throwing if it is missing or empty.
10
+ */
11
+ export function getRequiredEnv(key: string): string {
12
+ const value = process.env[key];
13
+ if (!value) {
14
+ throw new Error(`Missing required environment variable: ${key}`);
15
+ }
16
+ return value;
17
+ }
18
+
19
+ /**
20
+ * Returns the value of an environment variable, or a default if it is missing.
21
+ */
22
+ export function getOptionalEnv(key: string, defaultValue?: string): string | undefined {
23
+ return process.env[key] ?? defaultValue;
24
+ }
25
+
26
+ /**
27
+ * Asserts that all listed environment variable keys are present and non-empty.
28
+ * Throws a single error listing every missing variable instead of failing on the first one.
29
+ */
30
+ export function validateEnvVars(required: string[]): void {
31
+ const missing = required.filter((key) => !process.env[key]);
32
+ if (missing.length > 0) {
33
+ throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
34
+ }
35
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * API error handling and normalization
3
+ */
4
+
5
+ import type { DRFApiError } from '../types';
6
+
7
+ export class ApiException extends Error {
8
+ public status?: number;
9
+ public statusText?: string;
10
+ public errors?: Record<string, string[]>;
11
+ public detail?: string;
12
+
13
+ constructor(message: string, options?: Partial<DRFApiError>) {
14
+ super(message);
15
+ this.name = 'ApiException';
16
+ this.status = options?.status;
17
+ this.statusText = options?.statusText;
18
+ this.errors = options?.errors;
19
+ this.detail = options?.detail;
20
+ }
21
+
22
+ toJSON(): DRFApiError {
23
+ return {
24
+ message: this.message,
25
+ detail: this.detail,
26
+ status: this.status,
27
+ statusText: this.statusText,
28
+ errors: this.errors,
29
+ };
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Parse Django REST Framework error response
35
+ */
36
+ export async function parseErrorResponse(response: Response): Promise<DRFApiError> {
37
+ const contentType = response.headers.get('content-type');
38
+
39
+ // Try to parse JSON error
40
+ if (contentType?.includes('application/json')) {
41
+ try {
42
+ const data = await response.json();
43
+
44
+ // Django REST Framework error format
45
+ if (data.detail) {
46
+ return {
47
+ detail: data.detail,
48
+ message: data.detail,
49
+ status: response.status,
50
+ statusText: response.statusText,
51
+ };
52
+ }
53
+
54
+ // Validation errors format
55
+ if (typeof data === 'object') {
56
+ return {
57
+ errors: data,
58
+ message: 'Validation error',
59
+ status: response.status,
60
+ statusText: response.statusText,
61
+ };
62
+ }
63
+
64
+ return {
65
+ message: JSON.stringify(data),
66
+ status: response.status,
67
+ statusText: response.statusText,
68
+ };
69
+ } catch {
70
+ // JSON parse failed, fall through to text handling
71
+ }
72
+ }
73
+
74
+ // Fallback to text error
75
+ const text = await response.text().catch(() => response.statusText);
76
+
77
+ return {
78
+ message: text || response.statusText || 'Unknown error',
79
+ status: response.status,
80
+ statusText: response.statusText,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Handle fetch errors
86
+ */
87
+ export function handleFetchError(error: unknown): never {
88
+ if (error instanceof ApiException) {
89
+ throw error;
90
+ }
91
+
92
+ if (error instanceof TypeError && error.message.includes('fetch')) {
93
+ throw new ApiException('Network error - please check your connection', {
94
+ status: 0,
95
+ statusText: 'Network Error',
96
+ });
97
+ }
98
+
99
+ if (error instanceof Error) {
100
+ throw new ApiException(error.message, {
101
+ status: 0,
102
+ statusText: 'Client Error',
103
+ });
104
+ }
105
+
106
+ throw new ApiException('Unknown error occurred', {
107
+ status: 0,
108
+ statusText: 'Unknown Error',
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Check if error is an API exception
114
+ */
115
+ export function isApiException(error: unknown): error is ApiException {
116
+ return error instanceof ApiException;
117
+ }
118
+
119
+ /**
120
+ * Check if error is a validation error
121
+ */
122
+ export function isValidationError(error: unknown): error is ApiException {
123
+ return isApiException(error) && error.status === 400 && !!error.errors;
124
+ }
125
+
126
+ /**
127
+ * Check if error is an auth error
128
+ */
129
+ export function isAuthError(error: unknown): error is ApiException {
130
+ return isApiException(error) && (error.status === 401 || error.status === 403);
131
+ }
132
+
133
+ /**
134
+ * Check if error is a not found error
135
+ */
136
+ export function isNotFoundError(error: unknown): error is ApiException {
137
+ return isApiException(error) && error.status === 404;
138
+ }
@@ -0,0 +1,381 @@
1
+ /**
2
+ * AppError hierarchy for StartSimpli apps
3
+ *
4
+ * Provides a standardized error hierarchy with:
5
+ * - Consistent error codes and messages
6
+ * - HTTP status code mapping
7
+ * - Serialization for API responses
8
+ *
9
+ * These are server-side application errors (not API client fetch errors).
10
+ * For API client errors see ./error-handler.ts (ApiException).
11
+ */
12
+
13
+ /**
14
+ * Standard error codes used across the application
15
+ */
16
+ export enum AppErrorCode {
17
+ // Client errors (4xx)
18
+ VALIDATION_ERROR = 'VALIDATION_ERROR',
19
+ AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED',
20
+ INVALID_CREDENTIALS = 'INVALID_CREDENTIALS',
21
+ SESSION_EXPIRED = 'SESSION_EXPIRED',
22
+ FORBIDDEN = 'FORBIDDEN',
23
+ INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
24
+ NOT_FOUND = 'NOT_FOUND',
25
+ RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
26
+ CONFLICT = 'CONFLICT',
27
+ DUPLICATE_RESOURCE = 'DUPLICATE_RESOURCE',
28
+ RATE_LIMITED = 'RATE_LIMITED',
29
+ BAD_REQUEST = 'BAD_REQUEST',
30
+
31
+ // Server errors (5xx)
32
+ INTERNAL_ERROR = 'INTERNAL_ERROR',
33
+ DATABASE_ERROR = 'DATABASE_ERROR',
34
+ EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR',
35
+ SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
36
+ }
37
+
38
+ /**
39
+ * Shape of the error envelope returned from AppError.toResponse()
40
+ */
41
+ export interface AppErrorResponse {
42
+ error: {
43
+ code: AppErrorCode;
44
+ message: string;
45
+ details?: Record<string, unknown>;
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Base application error class.
51
+ * All custom errors should extend this class.
52
+ */
53
+ export class AppError extends Error {
54
+ public readonly code: AppErrorCode;
55
+ public readonly statusCode: number;
56
+ public readonly details?: Record<string, unknown>;
57
+ public readonly isOperational: boolean;
58
+
59
+ constructor(
60
+ message: string,
61
+ code: AppErrorCode = AppErrorCode.INTERNAL_ERROR,
62
+ statusCode: number = 500,
63
+ details?: Record<string, unknown>,
64
+ isOperational: boolean = true
65
+ ) {
66
+ super(message);
67
+ this.name = 'AppError';
68
+ this.code = code;
69
+ this.statusCode = statusCode;
70
+ this.details = details;
71
+ this.isOperational = isOperational;
72
+
73
+ // Maintains proper stack trace for where our error was thrown
74
+ Error.captureStackTrace(this, this.constructor);
75
+ }
76
+
77
+ /**
78
+ * Serialize error for API response
79
+ */
80
+ toResponse(): AppErrorResponse {
81
+ return {
82
+ error: {
83
+ code: this.code,
84
+ message: this.message,
85
+ ...(this.details && { details: this.details }),
86
+ },
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Check if an error is an operational error (expected) vs programming error
92
+ */
93
+ static isOperationalError(error: unknown): error is AppError {
94
+ return error instanceof AppError && error.isOperational;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Validation error for invalid request data
100
+ * HTTP 400 Bad Request
101
+ */
102
+ export class ValidationError extends AppError {
103
+ public readonly fieldErrors: Record<string, string[]>;
104
+
105
+ constructor(
106
+ message: string = 'Validation failed',
107
+ fieldErrors: Record<string, string[]> = {}
108
+ ) {
109
+ super(
110
+ message,
111
+ AppErrorCode.VALIDATION_ERROR,
112
+ 400,
113
+ { fields: fieldErrors }
114
+ );
115
+ this.name = 'ValidationError';
116
+ this.fieldErrors = fieldErrors;
117
+ }
118
+
119
+ /**
120
+ * Create from Zod error
121
+ */
122
+ static fromZodError(zodError: { errors: Array<{ path: (string | number)[]; message: string }> }): ValidationError {
123
+ const fieldErrors: Record<string, string[]> = {};
124
+
125
+ for (const error of zodError.errors) {
126
+ const path = error.path.join('.');
127
+ if (!fieldErrors[path]) {
128
+ fieldErrors[path] = [];
129
+ }
130
+ fieldErrors[path].push(error.message);
131
+ }
132
+
133
+ const message = Object.entries(fieldErrors)
134
+ .map(([field, errors]) => `${field}: ${errors.join(', ')}`)
135
+ .join('; ');
136
+
137
+ return new ValidationError(`Validation failed: ${message}`, fieldErrors);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Authentication error for unauthenticated requests
143
+ * HTTP 401 Unauthorized
144
+ */
145
+ export class AuthenticationError extends AppError {
146
+ constructor(
147
+ message: string = 'Authentication required',
148
+ code: AppErrorCode = AppErrorCode.AUTHENTICATION_REQUIRED
149
+ ) {
150
+ super(message, code, 401);
151
+ this.name = 'AuthenticationError';
152
+ }
153
+
154
+ /**
155
+ * Create for invalid credentials
156
+ */
157
+ static invalidCredentials(): AuthenticationError {
158
+ return new AuthenticationError('Invalid credentials', AppErrorCode.INVALID_CREDENTIALS);
159
+ }
160
+
161
+ /**
162
+ * Create for expired session
163
+ */
164
+ static sessionExpired(): AuthenticationError {
165
+ return new AuthenticationError('Session has expired', AppErrorCode.SESSION_EXPIRED);
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Authorization error for forbidden access
171
+ * HTTP 403 Forbidden
172
+ */
173
+ export class AuthorizationError extends AppError {
174
+ constructor(
175
+ message: string = 'Access denied',
176
+ code: AppErrorCode = AppErrorCode.FORBIDDEN
177
+ ) {
178
+ super(message, code, 403);
179
+ this.name = 'AuthorizationError';
180
+ }
181
+
182
+ /**
183
+ * Create for insufficient permissions
184
+ */
185
+ static insufficientPermissions(requiredPermission?: string): AuthorizationError {
186
+ const message = requiredPermission
187
+ ? `Insufficient permissions. Required: ${requiredPermission}`
188
+ : 'Insufficient permissions';
189
+ return new AuthorizationError(message, AppErrorCode.INSUFFICIENT_PERMISSIONS);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Not found error for missing resources
195
+ * HTTP 404 Not Found
196
+ */
197
+ export class NotFoundError extends AppError {
198
+ public readonly resourceType?: string;
199
+ public readonly resourceId?: string;
200
+
201
+ constructor(
202
+ message: string = 'Resource not found',
203
+ resourceType?: string,
204
+ resourceId?: string
205
+ ) {
206
+ super(
207
+ message,
208
+ AppErrorCode.NOT_FOUND,
209
+ 404,
210
+ resourceType || resourceId
211
+ ? { resourceType, resourceId }
212
+ : undefined
213
+ );
214
+ this.name = 'NotFoundError';
215
+ this.resourceType = resourceType;
216
+ this.resourceId = resourceId;
217
+ }
218
+
219
+ /**
220
+ * Create for a specific resource
221
+ */
222
+ static forResource(resourceType: string, resourceId?: string): NotFoundError {
223
+ const message = resourceId
224
+ ? `${resourceType} with ID '${resourceId}' not found`
225
+ : `${resourceType} not found`;
226
+ return new NotFoundError(message, resourceType, resourceId);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Conflict error for duplicate or conflicting resources
232
+ * HTTP 409 Conflict
233
+ */
234
+ export class ConflictError extends AppError {
235
+ public readonly conflictingField?: string;
236
+
237
+ constructor(
238
+ message: string = 'Resource conflict',
239
+ conflictingField?: string
240
+ ) {
241
+ super(
242
+ message,
243
+ AppErrorCode.CONFLICT,
244
+ 409,
245
+ conflictingField ? { field: conflictingField } : undefined
246
+ );
247
+ this.name = 'ConflictError';
248
+ this.conflictingField = conflictingField;
249
+ }
250
+
251
+ /**
252
+ * Create for duplicate resource
253
+ */
254
+ static duplicate(resourceType: string, field?: string): ConflictError {
255
+ const message = field
256
+ ? `A ${resourceType} with this ${field} already exists`
257
+ : `A ${resourceType} with these values already exists`;
258
+ return new ConflictError(message, field);
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Rate limit error for too many requests
264
+ * HTTP 429 Too Many Requests
265
+ */
266
+ export class RateLimitError extends AppError {
267
+ public readonly retryAfter?: number;
268
+
269
+ constructor(
270
+ message: string = 'Too many requests',
271
+ retryAfter?: number
272
+ ) {
273
+ super(
274
+ message,
275
+ AppErrorCode.RATE_LIMITED,
276
+ 429,
277
+ retryAfter ? { retryAfter } : undefined
278
+ );
279
+ this.name = 'RateLimitError';
280
+ this.retryAfter = retryAfter;
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Database error for database-related failures
286
+ * HTTP 500 Internal Server Error
287
+ */
288
+ export class DatabaseError extends AppError {
289
+ constructor(
290
+ message: string = 'Database operation failed',
291
+ details?: Record<string, unknown>
292
+ ) {
293
+ // Don't expose internal database details in production
294
+ const safeDetails = process.env.NODE_ENV === 'development' ? details : undefined;
295
+ super(message, AppErrorCode.DATABASE_ERROR, 500, safeDetails);
296
+ this.name = 'DatabaseError';
297
+ }
298
+
299
+ /**
300
+ * Create from Prisma error
301
+ */
302
+ static fromPrismaError(error: { code?: string; meta?: Record<string, unknown> }): DatabaseError {
303
+ const code = error.code;
304
+
305
+ // Map common Prisma error codes to user-friendly messages
306
+ switch (code) {
307
+ case 'P2002':
308
+ throw ConflictError.duplicate('record', (error.meta?.target as string[])?.[0]);
309
+ case 'P2025':
310
+ throw new NotFoundError('Record not found');
311
+ case 'P2003':
312
+ throw new ConflictError('Cannot perform operation due to related records');
313
+ default:
314
+ return new DatabaseError('Database operation failed', { code });
315
+ }
316
+ }
317
+ }
318
+
319
+ /**
320
+ * External service error for third-party API failures
321
+ * HTTP 502 Bad Gateway
322
+ */
323
+ export class ExternalServiceError extends AppError {
324
+ public readonly serviceName: string;
325
+
326
+ constructor(
327
+ serviceName: string,
328
+ message: string = 'External service error',
329
+ details?: Record<string, unknown>
330
+ ) {
331
+ super(
332
+ message,
333
+ AppErrorCode.EXTERNAL_SERVICE_ERROR,
334
+ 502,
335
+ { service: serviceName, ...details }
336
+ );
337
+ this.name = 'ExternalServiceError';
338
+ this.serviceName = serviceName;
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Type guard to check if an error has a code property (like Prisma errors)
344
+ */
345
+ export function isPrismaError(error: unknown): error is { code: string; meta?: Record<string, unknown> } {
346
+ return (
347
+ typeof error === 'object' &&
348
+ error !== null &&
349
+ 'code' in error &&
350
+ typeof (error as { code: unknown }).code === 'string'
351
+ );
352
+ }
353
+
354
+ /**
355
+ * Convert any error to an AppError.
356
+ * Useful for catch blocks to ensure consistent error handling.
357
+ */
358
+ export function toAppError(error: unknown): AppError {
359
+ if (error instanceof AppError) {
360
+ return error;
361
+ }
362
+
363
+ if (isPrismaError(error)) {
364
+ return DatabaseError.fromPrismaError(error);
365
+ }
366
+
367
+ if (error instanceof Error) {
368
+ return new AppError(
369
+ error.message,
370
+ AppErrorCode.INTERNAL_ERROR,
371
+ 500,
372
+ process.env.NODE_ENV === 'development' ? { stack: error.stack } : undefined
373
+ );
374
+ }
375
+
376
+ return new AppError(
377
+ 'An unexpected error occurred',
378
+ AppErrorCode.INTERNAL_ERROR,
379
+ 500
380
+ );
381
+ }