@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.
- package/README.md +329 -0
- package/package.json +42 -0
- package/src/__tests__/jwt-refresh.test.ts +195 -0
- package/src/__tests__/query-params.test.ts +144 -0
- package/src/__tests__/url-builder.test.ts +121 -0
- package/src/constants/endpoints.ts +39 -0
- package/src/index.ts +109 -0
- package/src/lib/api-client.ts +89 -0
- package/src/lib/contacts-api.ts +111 -0
- package/src/lib/cors.ts +122 -0
- package/src/lib/entities-api.ts +123 -0
- package/src/lib/env.ts +35 -0
- package/src/lib/error-handler.ts +138 -0
- package/src/lib/errors.ts +381 -0
- package/src/lib/fetch-wrapper.ts +188 -0
- package/src/lib/llm-sanitize.ts +145 -0
- package/src/lib/messages-api.ts +273 -0
- package/src/lib/messages-api.ts.backup +273 -0
- package/src/lib/organizations-api.ts +132 -0
- package/src/lib/rate-limit.ts +91 -0
- package/src/lib/sanitize.ts +39 -0
- package/src/lib/workflows-api.ts +159 -0
- package/src/middleware/index.ts +12 -0
- package/src/middleware/with-auth.ts +90 -0
- package/src/middleware/with-error-handling.ts +83 -0
- package/src/middleware/with-validation.ts +110 -0
- package/src/types/api.ts +38 -0
- package/src/types/contact.ts +49 -0
- package/src/types/entity.ts +153 -0
- package/src/types/error.ts +129 -0
- package/src/types/funnel.ts +133 -0
- package/src/types/index.ts +95 -0
- package/src/types/organization.ts +49 -0
- package/src/types/response.ts +44 -0
- package/src/types/workflow.ts +69 -0
- package/src/utils/index.ts +13 -0
- package/src/utils/query-params.ts +79 -0
- 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
|
+
}
|