@startsimpli/api 0.1.0 → 0.2.2

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.
@@ -7,7 +7,7 @@ import { buildUrl, buildQueryString } from '../utils';
7
7
  import { ApiException, parseErrorResponse, handleFetchError } from './error-handler';
8
8
 
9
9
  export interface FetchWrapperConfig {
10
- baseUrl: string;
10
+ baseUrl?: string;
11
11
  getToken?: () => Promise<string | null> | string | null;
12
12
  onUnauthorized?: () => void;
13
13
  onTokenRefresh?: () => Promise<string | null>;
@@ -66,7 +66,7 @@ export class FetchWrapper {
66
66
 
67
67
  // Build URL
68
68
  const url = buildUrl({
69
- baseUrl: this.config.baseUrl,
69
+ baseUrl: this.config.baseUrl ?? '',
70
70
  endpoint,
71
71
  params,
72
72
  });
@@ -83,31 +83,36 @@ export class FetchWrapper {
83
83
  });
84
84
 
85
85
  // Handle 401 Unauthorized - attempt token refresh
86
- if (response.status === 401 && this.config.onTokenRefresh) {
87
- // Prevent multiple simultaneous refresh attempts
88
- if (!this.isRefreshing) {
89
- this.isRefreshing = true;
90
- this.refreshPromise = this.config.onTokenRefresh().finally(() => {
91
- this.isRefreshing = false;
92
- this.refreshPromise = null;
93
- });
94
- }
95
-
96
- const newToken = await this.refreshPromise;
97
-
98
- if (newToken) {
99
- // Retry original request with new token
100
- const retryHeaders = new Headers(headers);
101
- retryHeaders.set('Authorization', `Bearer ${newToken}`);
102
-
103
- response = await fetch(url, {
104
- method,
105
- headers: retryHeaders,
106
- credentials: 'include',
107
- ...fetchOptions,
108
- });
86
+ if (response.status === 401) {
87
+ if (this.config.onTokenRefresh) {
88
+ // Prevent multiple simultaneous refresh attempts
89
+ if (!this.isRefreshing) {
90
+ this.isRefreshing = true;
91
+ this.refreshPromise = this.config.onTokenRefresh().finally(() => {
92
+ this.isRefreshing = false;
93
+ this.refreshPromise = null;
94
+ });
95
+ }
96
+
97
+ const newToken = await this.refreshPromise;
98
+
99
+ if (newToken) {
100
+ // Retry original request with new token
101
+ const retryHeaders = new Headers(headers);
102
+ retryHeaders.set('Authorization', `Bearer ${newToken}`);
103
+
104
+ response = await fetch(url, {
105
+ method,
106
+ headers: retryHeaders,
107
+ credentials: 'include',
108
+ ...fetchOptions,
109
+ });
110
+ } else if (this.config.onUnauthorized) {
111
+ // Refresh failed - call unauthorized callback
112
+ this.config.onUnauthorized();
113
+ }
109
114
  } else if (this.config.onUnauthorized) {
110
- // Refresh failed - call unauthorized callback
115
+ // No refresh callback - call unauthorized directly
111
116
  this.config.onUnauthorized();
112
117
  }
113
118
  }
@@ -130,6 +135,13 @@ export class FetchWrapper {
130
135
  }
131
136
  }
132
137
 
138
+ /**
139
+ * Return a new FetchWrapper with selected config fields overridden.
140
+ */
141
+ reconfigure(partial: Partial<FetchWrapperConfig>): FetchWrapper {
142
+ return new FetchWrapper({ ...this.config, ...partial });
143
+ }
144
+
133
145
  /**
134
146
  * GET request
135
147
  */
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Funnels API wrapper for /api/v1/funnels/
3
+ */
4
+
5
+ import type {
6
+ Funnel,
7
+ FunnelFilters,
8
+ FunnelRun,
9
+ FunnelResult,
10
+ CreateFunnelInput,
11
+ UpdateFunnelInput,
12
+ ExecuteFunnelInput,
13
+ PaginatedResponse,
14
+ PaginationParams,
15
+ } from '../types';
16
+ import { ENDPOINTS } from '../constants/endpoints';
17
+ import type { ApiClient } from './api-client';
18
+ import { isApiException } from './error-handler';
19
+
20
+ /**
21
+ * Returns true when the error is a 409 Conflict from trying to run a funnel that's already running.
22
+ */
23
+ export function isFunnelRunConflict(error: unknown): boolean {
24
+ return isApiException(error) && error.status === 409;
25
+ }
26
+
27
+ /**
28
+ * Returns true when the error is a 400 with validation errors (bad stage rules, missing entityType, etc.)
29
+ */
30
+ export function isFunnelValidationError(error: unknown): boolean {
31
+ return isApiException(error) && error.status === 400 && !!error.errors;
32
+ }
33
+
34
+ export interface FunnelPreviewResult {
35
+ total: number;
36
+ matched: number;
37
+ stages: Array<{
38
+ stageId: string;
39
+ stageName: string;
40
+ inputCount: number;
41
+ outputCount: number;
42
+ excluded: number;
43
+ }>;
44
+ }
45
+
46
+ export interface FunnelRunFilters {
47
+ page?: number;
48
+ pageSize?: number;
49
+ status?: FunnelRun['status'];
50
+ ordering?: string;
51
+ funnel?: string;
52
+ startedAfter?: string;
53
+ startedBefore?: string;
54
+ }
55
+
56
+ export interface FunnelTemplate {
57
+ id: string;
58
+ slug: string;
59
+ name: string;
60
+ description: string;
61
+ category: string;
62
+ icon: string;
63
+ isFeatured: boolean;
64
+ stageCount: number;
65
+ usageCount: number;
66
+ }
67
+
68
+ export class FunnelsApi {
69
+ constructor(private client: ApiClient) {}
70
+
71
+ /**
72
+ * List funnels with optional filters.
73
+ * Tags are passed as repeated query params: ?tags=campaign:abc&tags=product:market-simpli
74
+ */
75
+ async list(filters?: FunnelFilters): Promise<PaginatedResponse<Funnel>> {
76
+ const params = new URLSearchParams();
77
+
78
+ if (filters?.status) params.append('status', filters.status);
79
+ if (filters?.entityType) params.append('entityType', filters.entityType);
80
+ if (filters?.search) params.append('search', filters.search);
81
+ if (filters?.createdBy) params.append('createdBy', filters.createdBy);
82
+ if (filters?.page) params.append('page', String(filters.page));
83
+ if (filters?.pageSize) params.append('pageSize', String(filters.pageSize));
84
+ if (filters?.ordering) params.append('ordering', filters.ordering);
85
+
86
+ // tags is a multi-value param
87
+ if (filters?.tags?.length) {
88
+ for (const tag of filters.tags) {
89
+ params.append('tags', tag);
90
+ }
91
+ }
92
+
93
+ const query = params.toString();
94
+ const endpoint = query ? `${ENDPOINTS.FUNNELS}?${query}` : ENDPOINTS.FUNNELS;
95
+ return this.client.get<PaginatedResponse<Funnel>>(endpoint);
96
+ }
97
+
98
+ /**
99
+ * Get funnel by ID
100
+ */
101
+ async get(id: string): Promise<Funnel> {
102
+ return this.client.get<Funnel>(ENDPOINTS.FUNNEL(id));
103
+ }
104
+
105
+ /**
106
+ * Create a new funnel
107
+ */
108
+ async create(data: CreateFunnelInput): Promise<Funnel> {
109
+ return this.client.post<Funnel>(ENDPOINTS.FUNNELS, data);
110
+ }
111
+
112
+ /**
113
+ * Update funnel (partial)
114
+ */
115
+ async update(id: string, data: UpdateFunnelInput): Promise<Funnel> {
116
+ return this.client.patch<Funnel>(ENDPOINTS.FUNNEL(id), data);
117
+ }
118
+
119
+ /**
120
+ * Delete funnel
121
+ */
122
+ async delete(id: string): Promise<void> {
123
+ return this.client.delete<void>(ENDPOINTS.FUNNEL(id));
124
+ }
125
+
126
+ /**
127
+ * Execute a funnel run
128
+ */
129
+ async run(id: string, input?: ExecuteFunnelInput): Promise<FunnelRun> {
130
+ return this.client.post<FunnelRun>(ENDPOINTS.FUNNEL_RUN(id), input ?? {});
131
+ }
132
+
133
+ /**
134
+ * List run history for a funnel
135
+ */
136
+ async getRuns(
137
+ funnelId: string,
138
+ filters?: FunnelRunFilters
139
+ ): Promise<PaginatedResponse<FunnelRun>> {
140
+ const params = new URLSearchParams();
141
+ if (filters?.page) params.append('page', String(filters.page));
142
+ if (filters?.pageSize) params.append('pageSize', String(filters.pageSize));
143
+ if (filters?.status) params.append('status', filters.status);
144
+ if (filters?.ordering) params.append('ordering', filters.ordering);
145
+
146
+ const query = params.toString();
147
+ const endpoint = query
148
+ ? `${ENDPOINTS.FUNNEL_RUNS(funnelId)}?${query}`
149
+ : ENDPOINTS.FUNNEL_RUNS(funnelId);
150
+ return this.client.get<PaginatedResponse<FunnelRun>>(endpoint);
151
+ }
152
+
153
+ /**
154
+ * Get a specific run by ID. funnelId param is ignored (runs are accessed globally).
155
+ */
156
+ async getRun(_funnelId: string, runId: string): Promise<FunnelRun> {
157
+ return this.client.get<FunnelRun>(ENDPOINTS.FUNNEL_RUN_BY_ID(runId));
158
+ }
159
+
160
+ // BEAD: fund-your-startup-rgi4 - funnels/{id}/results endpoint missing from Django FunnelViewSet
161
+ async getResults(
162
+ _funnelId: string,
163
+ _pagination?: PaginationParams
164
+ ): Promise<PaginatedResponse<FunnelResult>> {
165
+ throw new Error('Not implemented - BEAD: fund-your-startup-rgi4. funnels/{id}/results action does not exist in Django.');
166
+ // const params = new URLSearchParams();
167
+ // if (_pagination?.page) params.append('page', String(_pagination.page));
168
+ // if (_pagination?.pageSize) params.append('pageSize', String(_pagination.pageSize));
169
+ // const query = params.toString();
170
+ // const endpoint = query ? `${ENDPOINTS.FUNNEL_RESULTS(_funnelId)}?${query}` : ENDPOINTS.FUNNEL_RESULTS(_funnelId);
171
+ // return this.client.get<PaginatedResponse<FunnelResult>>(endpoint);
172
+ }
173
+
174
+ // BEAD: fund-your-startup-rgi4 - funnels/{id}/preview endpoint missing. Django has /funnels/preview-icp/ (different signature).
175
+ async preview(_funnelId: string, _stages?: Funnel['stages']): Promise<FunnelPreviewResult> {
176
+ throw new Error('Not implemented - BEAD: fund-your-startup-rgi4. funnels/{id}/preview action does not exist in Django.');
177
+ // return this.client.post<FunnelPreviewResult>(ENDPOINTS.FUNNEL_PREVIEW(_funnelId), { stages: _stages });
178
+ }
179
+
180
+ /**
181
+ * List all funnel runs globally (across all funnels, scoped to current user)
182
+ */
183
+ async listRunsGlobal(filters?: FunnelRunFilters): Promise<PaginatedResponse<FunnelRun>> {
184
+ const params = new URLSearchParams();
185
+ if (filters?.funnel) params.append('funnel', filters.funnel);
186
+ if (filters?.status) params.append('status', filters.status);
187
+ if (filters?.page) params.append('page', String(filters.page));
188
+ if (filters?.pageSize) params.append('pageSize', String(filters.pageSize));
189
+ if (filters?.startedAfter) params.append('startedAfter', filters.startedAfter);
190
+ if (filters?.startedBefore) params.append('startedBefore', filters.startedBefore);
191
+
192
+ const query = params.toString();
193
+ const endpoint = query
194
+ ? `${ENDPOINTS.FUNNEL_RUNS_GLOBAL}?${query}`
195
+ : ENDPOINTS.FUNNEL_RUNS_GLOBAL;
196
+ return this.client.get<PaginatedResponse<FunnelRun>>(endpoint);
197
+ }
198
+
199
+ /**
200
+ * Cancel a running funnel run.
201
+ */
202
+ async cancelRun(runId: string): Promise<FunnelRun> {
203
+ return this.client.post<FunnelRun>(ENDPOINTS.FUNNEL_RUN_CANCEL(runId), {});
204
+ }
205
+
206
+ /**
207
+ * List available funnel templates
208
+ */
209
+ async listTemplates(filters?: { category?: string; page?: number; pageSize?: number }): Promise<PaginatedResponse<FunnelTemplate>> {
210
+ const params = new URLSearchParams();
211
+ if (filters?.category) params.append('category', filters.category);
212
+ if (filters?.page) params.append('page', String(filters.page));
213
+ if (filters?.pageSize) params.append('pageSize', String(filters.pageSize));
214
+
215
+ const query = params.toString();
216
+ const endpoint = query
217
+ ? `${ENDPOINTS.FUNNEL_TEMPLATES}?${query}`
218
+ : ENDPOINTS.FUNNEL_TEMPLATES;
219
+ return this.client.get<PaginatedResponse<FunnelTemplate>>(endpoint);
220
+ }
221
+ }
@@ -10,3 +10,6 @@ export type { ErrorApiHandler, ErrorResponseBody } from './with-error-handling';
10
10
 
11
11
  export { withValidation, composeMiddleware } from './with-validation';
12
12
  export type { ValidatedApiHandler, ValidationSchemas } from './with-validation';
13
+
14
+ export { withRateLimit } from './with-rate-limit';
15
+ export type { RateLimitCheckFn, WithRateLimitOptions } from './with-rate-limit';
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Rate limit middleware for Next.js API routes.
3
+ *
4
+ * Accepts a pre-configured rate-limiter function (from createRateLimiter)
5
+ * and a key extractor. Returns 429 when the limit is exceeded.
6
+ */
7
+
8
+ import type { NextRequest } from 'next/server'
9
+ import { NextResponse } from 'next/server'
10
+ import type { RateLimitResult } from '../lib/rate-limit'
11
+
12
+ export type RateLimitCheckFn = (key: string) => RateLimitResult
13
+
14
+ export interface WithRateLimitOptions {
15
+ /** Extract the rate limit key from the request (e.g. user ID or IP) */
16
+ getKey: (request: NextRequest) => string | Promise<string>
17
+ /** Human-readable message returned in the 429 error body */
18
+ message?: string
19
+ }
20
+
21
+ export function withRateLimit<T = unknown>(
22
+ handler: (request: NextRequest) => Promise<NextResponse<T>> | NextResponse<T>,
23
+ limiter: RateLimitCheckFn,
24
+ options: WithRateLimitOptions
25
+ ): (request: NextRequest) => Promise<NextResponse<T>> {
26
+ return async (request: NextRequest) => {
27
+ const key = await options.getKey(request)
28
+ const result = limiter(key)
29
+
30
+ if (!result.success) {
31
+ const retryAfter = result.retryAfter ?? Math.ceil((result.resetAt - Date.now()) / 1000)
32
+ return NextResponse.json(
33
+ {
34
+ success: false,
35
+ error: {
36
+ code: 'RATE_LIMITED',
37
+ message: options.message ?? 'Rate limit exceeded. Please try again later.',
38
+ retryable: true,
39
+ },
40
+ },
41
+ {
42
+ status: 429,
43
+ headers: {
44
+ 'Retry-After': String(retryAfter),
45
+ 'X-RateLimit-Remaining': '0',
46
+ 'X-RateLimit-Reset': String(Math.ceil(result.resetAt / 1000)),
47
+ },
48
+ }
49
+ ) as NextResponse<T>
50
+ }
51
+
52
+ return handler(request)
53
+ }
54
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Django REST Framework response transformations
3
+ *
4
+ * Normalizes DRF paginated responses into a frontend-friendly shape.
5
+ */
6
+
7
+ /**
8
+ * Raw DRF paginated response shape
9
+ */
10
+ export interface DRFPaginatedResponse<T> {
11
+ count: number;
12
+ next: string | null;
13
+ previous: string | null;
14
+ results: T[];
15
+ }
16
+
17
+ /**
18
+ * Normalized paginated response for frontend consumption
19
+ */
20
+ export interface NormalizedPaginatedResponse<T> {
21
+ items: T[];
22
+ total: number;
23
+ page: number;
24
+ pageSize: number;
25
+ hasNext: boolean;
26
+ hasPrev: boolean;
27
+ }
28
+
29
+ /**
30
+ * Normalize a DRF paginated response into a frontend-friendly format.
31
+ *
32
+ * @param response - Raw DRF paginated response
33
+ * @param page - Current page number (1-based)
34
+ * @param pageSize - Items per page
35
+ */
36
+ export function normalizePaginated<T>(
37
+ response: DRFPaginatedResponse<T>,
38
+ page: number,
39
+ pageSize: number
40
+ ): NormalizedPaginatedResponse<T> {
41
+ const total = response.count || 0;
42
+ return {
43
+ items: response.results || [],
44
+ total,
45
+ page,
46
+ pageSize,
47
+ hasNext: response.next !== null,
48
+ hasPrev: response.previous !== null,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Check if a response is a DRF paginated response
54
+ */
55
+ export function isDRFPaginatedResponse<T = unknown>(
56
+ response: unknown
57
+ ): response is DRFPaginatedResponse<T> {
58
+ return (
59
+ typeof response === 'object' &&
60
+ response !== null &&
61
+ 'results' in response &&
62
+ 'count' in response
63
+ );
64
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Generic Entity Query Builder
3
+ *
4
+ * Builds server-side query parameters for entity filtering.
5
+ * Supports tags, metrics, profiles, attributes, pagination, search, sorting, and date ranges.
6
+ *
7
+ * Usage:
8
+ * const params = new EntityQueryBuilder()
9
+ * .withTags('quality:tier_1', 'status:prospect')
10
+ * .withMetrics('financial:aum__gte:100000000')
11
+ * .paginate(1, 25)
12
+ * .search('acme')
13
+ * .sort('name', 'asc')
14
+ * .build()
15
+ */
16
+
17
+ export class EntityQueryBuilder {
18
+ private params: Map<string, string> = new Map();
19
+
20
+ /**
21
+ * Filter by entity type
22
+ */
23
+ entityType(type: 'CONTACT' | 'ORGANIZATION'): this {
24
+ this.params.set('entity_type', type);
25
+ return this;
26
+ }
27
+
28
+ /**
29
+ * Add tag filters (compact format: "category:name")
30
+ *
31
+ * @example
32
+ * .withTags('quality:tier_1', 'status:prospect')
33
+ * // produces: tags=quality:tier_1,status:prospect
34
+ */
35
+ withTags(...tags: string[]): this {
36
+ if (tags.length > 0) {
37
+ const existing = this.params.get('tags');
38
+ const combined = existing ? `${existing},${tags.join(',')}` : tags.join(',');
39
+ this.params.set('tags', combined);
40
+ }
41
+ return this;
42
+ }
43
+
44
+ /**
45
+ * Add metric filters (compact format: "type:subtype__operator:value")
46
+ *
47
+ * @example
48
+ * .withMetrics('financial:aum__gte:100000000', 'check_size:min__gte:1000000')
49
+ */
50
+ withMetrics(...metrics: string[]): this {
51
+ if (metrics.length > 0) {
52
+ const existing = this.params.get('metrics');
53
+ const combined = existing ? `${existing},${metrics.join(',')}` : metrics.join(',');
54
+ this.params.set('metrics', combined);
55
+ }
56
+ return this;
57
+ }
58
+
59
+ /**
60
+ * Add profile filters (compact format: "type:subtype")
61
+ *
62
+ * @example
63
+ * .withProfiles('professional:linkedin', 'social:twitter')
64
+ */
65
+ withProfiles(...profiles: string[]): this {
66
+ if (profiles.length > 0) {
67
+ const existing = this.params.get('profiles');
68
+ const combined = existing ? `${existing},${profiles.join(',')}` : profiles.join(',');
69
+ this.params.set('profiles', combined);
70
+ }
71
+ return this;
72
+ }
73
+
74
+ /**
75
+ * Add attribute filters (compact format: "type:subtype:value")
76
+ *
77
+ * @example
78
+ * .withAttributes('demographic:location:san_francisco')
79
+ */
80
+ withAttributes(...attrs: string[]): this {
81
+ if (attrs.length > 0) {
82
+ const existing = this.params.get('attributes');
83
+ const combined = existing ? `${existing},${attrs.join(',')}` : attrs.join(',');
84
+ this.params.set('attributes', combined);
85
+ }
86
+ return this;
87
+ }
88
+
89
+ /**
90
+ * Set pagination parameters
91
+ */
92
+ paginate(page: number, pageSize: number): this {
93
+ this.params.set('page', String(page));
94
+ this.params.set('page_size', String(pageSize));
95
+ return this;
96
+ }
97
+
98
+ /**
99
+ * Set search query
100
+ */
101
+ search(query: string): this {
102
+ const trimmed = query.trim();
103
+ if (trimmed) {
104
+ this.params.set('search', trimmed);
105
+ }
106
+ return this;
107
+ }
108
+
109
+ /**
110
+ * Set sort field and direction (maps to Django's `ordering` param)
111
+ */
112
+ sort(field: string, direction: 'asc' | 'desc' = 'asc'): this {
113
+ const prefix = direction === 'desc' ? '-' : '';
114
+ this.params.set('ordering', `${prefix}${field}`);
115
+ return this;
116
+ }
117
+
118
+ /**
119
+ * Add a date range filter on a given field
120
+ *
121
+ * @example
122
+ * .withDateRange('created', new Date('2024-01-01'), new Date('2024-12-31'))
123
+ * // produces: created_after=2024-01-01&created_before=2024-12-31
124
+ */
125
+ withDateRange(field: string, from?: Date, to?: Date): this {
126
+ if (from) {
127
+ this.params.set(`${field}_after`, from.toISOString().split('T')[0]);
128
+ }
129
+ if (to) {
130
+ this.params.set(`${field}_before`, to.toISOString().split('T')[0]);
131
+ }
132
+ return this;
133
+ }
134
+
135
+ /**
136
+ * Set an arbitrary query parameter
137
+ */
138
+ param(key: string, value: string): this {
139
+ this.params.set(key, value);
140
+ return this;
141
+ }
142
+
143
+ /**
144
+ * Build as a plain object (suitable for URLSearchParams or fetch helpers)
145
+ */
146
+ build(): Record<string, string> {
147
+ const result: Record<string, string> = {};
148
+ this.params.forEach((value, key) => {
149
+ result[key] = value;
150
+ });
151
+ return result;
152
+ }
153
+
154
+ /**
155
+ * Build as a query string (includes leading `?`)
156
+ * Returns empty string if no params.
157
+ */
158
+ toQueryString(): string {
159
+ if (this.params.size === 0) return '';
160
+ const searchParams = new URLSearchParams();
161
+ this.params.forEach((value, key) => {
162
+ searchParams.set(key, value);
163
+ });
164
+ return `?${searchParams.toString()}`;
165
+ }
166
+
167
+ /**
168
+ * Reset all parameters
169
+ */
170
+ clear(): this {
171
+ this.params.clear();
172
+ return this;
173
+ }
174
+ }
@@ -2,7 +2,7 @@
2
2
  * Utility exports for @startsimpli/api
3
3
  */
4
4
 
5
- export { buildUrl, buildQueryString, normalizeId } from './url-builder';
5
+ export { buildUrl, buildQueryString, normalizeId, resolveApiUrl } from './url-builder';
6
6
  export type { UrlBuilderOptions } from './url-builder';
7
7
 
8
8
  export {
@@ -11,3 +11,13 @@ export {
11
11
  mergeQueryParams,
12
12
  } from './query-params';
13
13
  export type { DjangoFilterParams } from './query-params';
14
+
15
+ export { validateApiResponse } from './validate-response';
16
+
17
+ export { EntityQueryBuilder } from './entity-query-builder';
18
+
19
+ export { normalizePaginated, isDRFPaginatedResponse } from './drf-transforms';
20
+ export type {
21
+ DRFPaginatedResponse,
22
+ NormalizedPaginatedResponse,
23
+ } from './drf-transforms';
@@ -66,6 +66,33 @@ export function buildQueryString(params: Record<string, unknown>): string {
66
66
  return searchParams.toString();
67
67
  }
68
68
 
69
+ /**
70
+ * Resolve a full or partial API path to a final URL.
71
+ *
72
+ * Handles four cases:
73
+ * - Absolute input URL → returned unchanged
74
+ * - Absolute baseUrl → joined via URL constructor
75
+ * - Relative baseUrl → string concatenation
76
+ * - Empty / undefined baseUrl → path returned as-is (suits Next.js proxy pattern)
77
+ */
78
+ export function resolveApiUrl(path: string, baseUrl: string = ''): string {
79
+ // Already a full URL — pass through unchanged
80
+ if (/^https?:\/\//i.test(path)) return path;
81
+
82
+ const normalized = path.startsWith('/') ? path : `/${path}`;
83
+
84
+ if (!baseUrl) return normalized;
85
+
86
+ // Absolute base: use URL constructor for correct joining
87
+ if (/^https?:\/\//i.test(baseUrl)) {
88
+ return new URL(normalized, baseUrl).toString();
89
+ }
90
+
91
+ // Relative base: strip trailing slash, prepend
92
+ const cleanBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
93
+ return `${cleanBase}${normalized}`;
94
+ }
95
+
69
96
  /**
70
97
  * Extract ID from URL or return as-is
71
98
  */