@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,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Funnel types matching Django Funnel models
|
|
3
|
+
*
|
|
4
|
+
* Funnels are brutally generic - they can represent:
|
|
5
|
+
* - Lead scoring funnels
|
|
6
|
+
* - Investor qualification stages
|
|
7
|
+
* - Customer segmentation
|
|
8
|
+
* - Any multi-stage entity classification
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type FunnelStatus = 'draft' | 'active' | 'archived';
|
|
12
|
+
export type EntityType = 'contact' | 'organization';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Filter rule for funnel stage
|
|
16
|
+
*/
|
|
17
|
+
export interface FunnelStageRule {
|
|
18
|
+
id: number;
|
|
19
|
+
fieldPath: string;
|
|
20
|
+
operator: 'eq' | 'ne' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'notContains' | 'startswith' | 'endswith' | 'in' | 'notIn' | 'isnull' | 'isnotnull' | 'arrayContains' | 'arrayOverlaps';
|
|
21
|
+
value: string | number | boolean | null;
|
|
22
|
+
logic?: 'AND' | 'OR';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Funnel stage with rules
|
|
27
|
+
*/
|
|
28
|
+
export interface FunnelStage {
|
|
29
|
+
id: number;
|
|
30
|
+
name: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
order: number;
|
|
33
|
+
rules: FunnelStageRule[];
|
|
34
|
+
entityCount?: number;
|
|
35
|
+
createdAt: string;
|
|
36
|
+
updatedAt: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Funnel - brutally generic entity classification system
|
|
41
|
+
*/
|
|
42
|
+
export interface Funnel {
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
description: string;
|
|
46
|
+
status: FunnelStatus;
|
|
47
|
+
tags?: string[];
|
|
48
|
+
entityType: EntityType;
|
|
49
|
+
/**
|
|
50
|
+
* @deprecated Legacy field for backward compatibility
|
|
51
|
+
*/
|
|
52
|
+
inputType?: 'contacts' | 'organizations' | 'both' | 'any';
|
|
53
|
+
stages: FunnelStage[];
|
|
54
|
+
stageCount: number;
|
|
55
|
+
totalRuns: number;
|
|
56
|
+
lastRunAt: string | null;
|
|
57
|
+
createdBy: string;
|
|
58
|
+
createdByName: string | null;
|
|
59
|
+
createdAt: string;
|
|
60
|
+
updatedAt: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Funnel result for a specific entity
|
|
65
|
+
*/
|
|
66
|
+
export interface FunnelResult {
|
|
67
|
+
id: number;
|
|
68
|
+
funnel: string;
|
|
69
|
+
entity: string;
|
|
70
|
+
entityType: EntityType;
|
|
71
|
+
stage: number | null;
|
|
72
|
+
stageName: string | null;
|
|
73
|
+
score: number | null;
|
|
74
|
+
metadata: Record<string, any>;
|
|
75
|
+
createdAt: string;
|
|
76
|
+
updatedAt: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Funnel execution run
|
|
81
|
+
*/
|
|
82
|
+
export interface FunnelRun {
|
|
83
|
+
id: string;
|
|
84
|
+
funnel: string;
|
|
85
|
+
status: 'pending' | 'running' | 'completed' | 'failed';
|
|
86
|
+
totalEntities: number;
|
|
87
|
+
processedEntities: number;
|
|
88
|
+
resultsByStage: Record<string, number>;
|
|
89
|
+
startedAt: string | null;
|
|
90
|
+
completedAt: string | null;
|
|
91
|
+
errorMessage: string | null;
|
|
92
|
+
createdBy: string;
|
|
93
|
+
createdAt: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Filters for listing funnels
|
|
98
|
+
*/
|
|
99
|
+
export interface FunnelFilters {
|
|
100
|
+
status?: FunnelStatus;
|
|
101
|
+
entityType?: EntityType;
|
|
102
|
+
search?: string;
|
|
103
|
+
tags?: string[];
|
|
104
|
+
createdBy?: string;
|
|
105
|
+
page?: number;
|
|
106
|
+
pageSize?: number;
|
|
107
|
+
ordering?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Input for creating a funnel
|
|
112
|
+
*/
|
|
113
|
+
export interface CreateFunnelInput {
|
|
114
|
+
name: string;
|
|
115
|
+
description?: string;
|
|
116
|
+
status?: FunnelStatus;
|
|
117
|
+
tags?: string[];
|
|
118
|
+
entityType: EntityType;
|
|
119
|
+
stages?: Omit<FunnelStage, 'id' | 'createdAt' | 'updatedAt' | 'entityCount'>[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Input for updating a funnel
|
|
124
|
+
*/
|
|
125
|
+
export interface UpdateFunnelInput extends Partial<CreateFunnelInput> {}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Input for executing a funnel
|
|
129
|
+
*/
|
|
130
|
+
export interface ExecuteFunnelInput {
|
|
131
|
+
entityIds?: string[];
|
|
132
|
+
filters?: Record<string, any>;
|
|
133
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type exports for @startsimpli/api
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// API types
|
|
6
|
+
export type {
|
|
7
|
+
PaginatedResponse,
|
|
8
|
+
PaginationParams,
|
|
9
|
+
SortParams,
|
|
10
|
+
ApiRequestConfig,
|
|
11
|
+
HttpMethod,
|
|
12
|
+
FetchOptions,
|
|
13
|
+
} from './api';
|
|
14
|
+
// DRF-specific ApiError (aliased to avoid conflict with canonical ApiError below)
|
|
15
|
+
export type { ApiError as DRFApiError } from './api';
|
|
16
|
+
|
|
17
|
+
// Canonical response envelope types (used by all StartSimpli apps)
|
|
18
|
+
export type {
|
|
19
|
+
ApiError,
|
|
20
|
+
ApiResponse,
|
|
21
|
+
AsyncState,
|
|
22
|
+
AsyncStatus,
|
|
23
|
+
} from './response';
|
|
24
|
+
export { ErrorCodes } from './response';
|
|
25
|
+
|
|
26
|
+
// Entity types
|
|
27
|
+
export type {
|
|
28
|
+
EntityType,
|
|
29
|
+
Entity,
|
|
30
|
+
Tag,
|
|
31
|
+
EntityTag,
|
|
32
|
+
Metric,
|
|
33
|
+
Profile,
|
|
34
|
+
Attribute,
|
|
35
|
+
Relationship,
|
|
36
|
+
RoleAssignment,
|
|
37
|
+
Event,
|
|
38
|
+
EventParticipant,
|
|
39
|
+
Source,
|
|
40
|
+
WritableAssertions,
|
|
41
|
+
} from './entity';
|
|
42
|
+
|
|
43
|
+
// Contact types
|
|
44
|
+
export type {
|
|
45
|
+
Contact,
|
|
46
|
+
CreateContactRequest,
|
|
47
|
+
UpdateContactRequest,
|
|
48
|
+
ContactFilters,
|
|
49
|
+
} from './contact';
|
|
50
|
+
|
|
51
|
+
// Organization types
|
|
52
|
+
export type {
|
|
53
|
+
Organization,
|
|
54
|
+
CreateOrganizationRequest,
|
|
55
|
+
UpdateOrganizationRequest,
|
|
56
|
+
OrganizationFilters,
|
|
57
|
+
} from './organization';
|
|
58
|
+
|
|
59
|
+
// Funnel types
|
|
60
|
+
export type {
|
|
61
|
+
FunnelStatus,
|
|
62
|
+
FunnelStageRule,
|
|
63
|
+
FunnelStage,
|
|
64
|
+
Funnel,
|
|
65
|
+
FunnelResult,
|
|
66
|
+
FunnelRun,
|
|
67
|
+
FunnelFilters,
|
|
68
|
+
CreateFunnelInput,
|
|
69
|
+
UpdateFunnelInput,
|
|
70
|
+
ExecuteFunnelInput,
|
|
71
|
+
} from './funnel';
|
|
72
|
+
|
|
73
|
+
// Workflow types
|
|
74
|
+
export type {
|
|
75
|
+
WorkflowStatus,
|
|
76
|
+
Workflow,
|
|
77
|
+
WorkflowExecution,
|
|
78
|
+
WorkflowFilters,
|
|
79
|
+
ExecuteWorkflowInput,
|
|
80
|
+
CreateWorkflowInput,
|
|
81
|
+
UpdateWorkflowInput,
|
|
82
|
+
} from './workflow';
|
|
83
|
+
|
|
84
|
+
// Error types
|
|
85
|
+
export type {
|
|
86
|
+
FieldError,
|
|
87
|
+
StandardErrorResponse,
|
|
88
|
+
} from './error';
|
|
89
|
+
export {
|
|
90
|
+
ErrorCode,
|
|
91
|
+
ErrorMessages,
|
|
92
|
+
getErrorCodeFromStatus,
|
|
93
|
+
FieldErrorSchema,
|
|
94
|
+
StandardErrorResponseSchema,
|
|
95
|
+
} from './error';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Organization types matching Django Organization model
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Entity, WritableAssertions } from './entity';
|
|
6
|
+
|
|
7
|
+
export interface Organization extends Entity {
|
|
8
|
+
name: string;
|
|
9
|
+
domain?: string | null;
|
|
10
|
+
description?: string | null;
|
|
11
|
+
location?: string | null;
|
|
12
|
+
|
|
13
|
+
// Computed fields from assertions
|
|
14
|
+
tier?: number | null;
|
|
15
|
+
stage?: string | null;
|
|
16
|
+
focusAreas?: string[];
|
|
17
|
+
checkSizeMin?: number | null;
|
|
18
|
+
checkSizeMax?: number | null;
|
|
19
|
+
aum?: number | string | null;
|
|
20
|
+
portfolioCount?: number | null;
|
|
21
|
+
linkedin?: string | null;
|
|
22
|
+
twitter?: string | null;
|
|
23
|
+
crunchbase?: string | null;
|
|
24
|
+
website?: string | null;
|
|
25
|
+
foundedYear?: number | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CreateOrganizationRequest extends WritableAssertions {
|
|
29
|
+
name: string;
|
|
30
|
+
domain?: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
location?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface UpdateOrganizationRequest extends Partial<CreateOrganizationRequest> {}
|
|
36
|
+
|
|
37
|
+
export interface OrganizationFilters {
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
search?: string;
|
|
40
|
+
tier?: number;
|
|
41
|
+
stage?: string;
|
|
42
|
+
focusArea?: string;
|
|
43
|
+
checkSizeMinGte?: number;
|
|
44
|
+
checkSizeMaxLte?: number;
|
|
45
|
+
aumGte?: number;
|
|
46
|
+
location?: string;
|
|
47
|
+
tagCategory?: string;
|
|
48
|
+
tagName?: string;
|
|
49
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Canonical API response envelope used by all StartSimpli apps
|
|
2
|
+
export interface ApiError {
|
|
3
|
+
code: string;
|
|
4
|
+
message: string;
|
|
5
|
+
details?: unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ApiResponse<T = unknown> {
|
|
9
|
+
success: boolean;
|
|
10
|
+
data?: T;
|
|
11
|
+
error?: ApiError;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Standard error codes (apps may define additional domain-specific codes)
|
|
15
|
+
export enum ErrorCodes {
|
|
16
|
+
UNAUTHORIZED = 'UNAUTHORIZED',
|
|
17
|
+
FORBIDDEN = 'FORBIDDEN',
|
|
18
|
+
NOT_FOUND = 'NOT_FOUND',
|
|
19
|
+
VALIDATION_ERROR = 'VALIDATION_ERROR',
|
|
20
|
+
CONFLICT = 'CONFLICT',
|
|
21
|
+
INTERNAL_ERROR = 'INTERNAL_ERROR',
|
|
22
|
+
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
|
|
23
|
+
RATE_LIMITED = 'RATE_LIMITED',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PaginationParams {
|
|
27
|
+
page?: number;
|
|
28
|
+
pageSize?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PaginatedResponse<T> {
|
|
32
|
+
results: T[];
|
|
33
|
+
count: number;
|
|
34
|
+
next: string | null;
|
|
35
|
+
previous: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Async state pattern for React components
|
|
39
|
+
export type AsyncStatus = 'idle' | 'loading' | 'success' | 'error';
|
|
40
|
+
export interface AsyncState<T> {
|
|
41
|
+
status: AsyncStatus;
|
|
42
|
+
data: T | null;
|
|
43
|
+
error: string | null;
|
|
44
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow types matching Django Workflow models
|
|
3
|
+
*
|
|
4
|
+
* Workflows are n8n-style node-based automation flows
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type WorkflowStatus = 'draft' | 'active' | 'paused';
|
|
8
|
+
|
|
9
|
+
export interface Workflow {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
team: string;
|
|
14
|
+
createdBy: string | null;
|
|
15
|
+
nodes: any[];
|
|
16
|
+
connections: Record<string, any>;
|
|
17
|
+
settings: Record<string, any>;
|
|
18
|
+
staticData: Record<string, any>;
|
|
19
|
+
isActive: boolean;
|
|
20
|
+
version: number;
|
|
21
|
+
isTemplate: boolean;
|
|
22
|
+
templateSource: string | null;
|
|
23
|
+
executionsCount: number;
|
|
24
|
+
createdAt: string;
|
|
25
|
+
updatedAt: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WorkflowExecution {
|
|
29
|
+
id: string;
|
|
30
|
+
workflow: string;
|
|
31
|
+
workflowVersion: number;
|
|
32
|
+
triggeredBy: string;
|
|
33
|
+
status: 'pending' | 'running' | 'completed' | 'failed' | 'paused';
|
|
34
|
+
mode: 'manual' | 'trigger' | 'webhook';
|
|
35
|
+
contextData: Record<string, any>;
|
|
36
|
+
state: Record<string, any>;
|
|
37
|
+
startedAt: string | null;
|
|
38
|
+
completedAt: string | null;
|
|
39
|
+
waitUntil: string | null;
|
|
40
|
+
errorMessage: string | null;
|
|
41
|
+
createdAt: string;
|
|
42
|
+
updatedAt: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface WorkflowFilters {
|
|
46
|
+
team?: string;
|
|
47
|
+
isActive?: boolean;
|
|
48
|
+
isTemplate?: boolean;
|
|
49
|
+
page?: number;
|
|
50
|
+
pageSize?: number;
|
|
51
|
+
ordering?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ExecuteWorkflowInput {
|
|
55
|
+
contextData?: Record<string, any>;
|
|
56
|
+
mode?: 'manual' | 'trigger' | 'webhook';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CreateWorkflowInput {
|
|
60
|
+
name: string;
|
|
61
|
+
description?: string;
|
|
62
|
+
team: string;
|
|
63
|
+
nodes?: any[];
|
|
64
|
+
connections?: Record<string, any>;
|
|
65
|
+
settings?: Record<string, any>;
|
|
66
|
+
isActive?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface UpdateWorkflowInput extends Partial<CreateWorkflowInput> {}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility exports for @startsimpli/api
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { buildUrl, buildQueryString, normalizeId } from './url-builder';
|
|
6
|
+
export type { UrlBuilderOptions } from './url-builder';
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
buildFilterParams,
|
|
10
|
+
buildOrderingParam,
|
|
11
|
+
mergeQueryParams,
|
|
12
|
+
} from './query-params';
|
|
13
|
+
export type { DjangoFilterParams } from './query-params';
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Django query parameter helpers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { PaginationParams, SortParams } from '../types';
|
|
6
|
+
|
|
7
|
+
export interface DjangoFilterParams extends PaginationParams, SortParams {
|
|
8
|
+
search?: string;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build Django filter params from filters object
|
|
14
|
+
*/
|
|
15
|
+
export function buildFilterParams<T extends Record<string, unknown>>(
|
|
16
|
+
filters: T
|
|
17
|
+
): Record<string, unknown> {
|
|
18
|
+
const params: Record<string, unknown> = {};
|
|
19
|
+
|
|
20
|
+
Object.entries(filters).forEach(([key, value]) => {
|
|
21
|
+
if (value === undefined || value === null) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Handle boolean filters (e.g., has_email=true)
|
|
26
|
+
if (typeof value === 'boolean') {
|
|
27
|
+
params[key] = value;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Handle array filters (e.g., tier__in=[1,2,3])
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
if (value.length > 0) {
|
|
34
|
+
params[`${key}__in`] = value.join(',');
|
|
35
|
+
}
|
|
36
|
+
return; // Skip empty arrays
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Handle range filters (e.g., check_size_min__gte=100000)
|
|
40
|
+
if (key.endsWith('_gte') || key.endsWith('_lte') || key.endsWith('_gt') || key.endsWith('_lt')) {
|
|
41
|
+
params[key] = value;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Default: exact match
|
|
46
|
+
params[key] = value;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return params;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build Django ordering param from sort config
|
|
54
|
+
*/
|
|
55
|
+
export function buildOrderingParam(
|
|
56
|
+
field?: string,
|
|
57
|
+
direction: 'asc' | 'desc' = 'asc'
|
|
58
|
+
): string | undefined {
|
|
59
|
+
if (!field) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return direction === 'desc' ? `-${field}` : field;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Merge pagination, sorting, and filter params
|
|
68
|
+
*/
|
|
69
|
+
export function mergeQueryParams(
|
|
70
|
+
pagination?: PaginationParams,
|
|
71
|
+
sorting?: SortParams,
|
|
72
|
+
filters?: Record<string, unknown>
|
|
73
|
+
): Record<string, unknown> {
|
|
74
|
+
return {
|
|
75
|
+
...filters,
|
|
76
|
+
...pagination,
|
|
77
|
+
...sorting,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL construction utilities for Django REST API
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface UrlBuilderOptions {
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
endpoint: string;
|
|
8
|
+
params?: Record<string, unknown>;
|
|
9
|
+
id?: string | number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build API URL with query parameters
|
|
14
|
+
*/
|
|
15
|
+
export function buildUrl({ baseUrl, endpoint, params, id }: UrlBuilderOptions): string {
|
|
16
|
+
// Remove trailing slash from baseUrl, leading slash from endpoint
|
|
17
|
+
const cleanBase = baseUrl.replace(/\/$/, '');
|
|
18
|
+
const cleanEndpoint = endpoint.replace(/^\//, '');
|
|
19
|
+
|
|
20
|
+
// Build path with optional ID
|
|
21
|
+
let path = `${cleanBase}/${cleanEndpoint}`;
|
|
22
|
+
if (id !== undefined) {
|
|
23
|
+
path += `/${id}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Add trailing slash (Django convention)
|
|
27
|
+
if (!path.endsWith('/')) {
|
|
28
|
+
path += '/';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Add query parameters
|
|
32
|
+
if (params && Object.keys(params).length > 0) {
|
|
33
|
+
const queryString = buildQueryString(params);
|
|
34
|
+
if (queryString) {
|
|
35
|
+
path += `?${queryString}`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return path;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build query string from params object
|
|
44
|
+
*/
|
|
45
|
+
export function buildQueryString(params: Record<string, unknown>): string {
|
|
46
|
+
const searchParams = new URLSearchParams();
|
|
47
|
+
|
|
48
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
49
|
+
if (value === undefined || value === null) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
// Django handles arrays with comma-separated values
|
|
55
|
+
value.forEach((item) => {
|
|
56
|
+
searchParams.append(key, String(item));
|
|
57
|
+
});
|
|
58
|
+
} else if (typeof value === 'object') {
|
|
59
|
+
// Serialize objects as JSON
|
|
60
|
+
searchParams.append(key, JSON.stringify(value));
|
|
61
|
+
} else {
|
|
62
|
+
searchParams.append(key, String(value));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return searchParams.toString();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Extract ID from URL or return as-is
|
|
71
|
+
*/
|
|
72
|
+
export function normalizeId(id: string | number): string | number {
|
|
73
|
+
if (typeof id === 'string' && id.includes('/')) {
|
|
74
|
+
const parts = id.split('/').filter(Boolean);
|
|
75
|
+
return parts[parts.length - 1];
|
|
76
|
+
}
|
|
77
|
+
return id;
|
|
78
|
+
}
|