@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,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Messages API wrapper
|
|
3
|
+
*
|
|
4
|
+
* Provides type-safe access to Django Messages API
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ApiClient } from './api-client';
|
|
8
|
+
|
|
9
|
+
export type MessageStatus = 'draft' | 'scheduled' | 'sending' | 'sent' | 'failed';
|
|
10
|
+
export type MessageContentType = 'text/plain' | 'text/markdown' | 'text/html';
|
|
11
|
+
export type RecipientStatus = 'pending' | 'sent' | 'delivered' | 'bounced' | 'opened' | 'clicked';
|
|
12
|
+
|
|
13
|
+
export interface Message {
|
|
14
|
+
id: string;
|
|
15
|
+
team: string;
|
|
16
|
+
channel: string;
|
|
17
|
+
channel_details?: {
|
|
18
|
+
key: string;
|
|
19
|
+
name: string;
|
|
20
|
+
description: string;
|
|
21
|
+
icon: string;
|
|
22
|
+
};
|
|
23
|
+
subject: string;
|
|
24
|
+
body: string;
|
|
25
|
+
content_type: MessageContentType;
|
|
26
|
+
from_name: string | null;
|
|
27
|
+
from_email: string | null;
|
|
28
|
+
reply_to: string | null;
|
|
29
|
+
status: MessageStatus;
|
|
30
|
+
scheduled_at: string | null;
|
|
31
|
+
sent_at: string | null;
|
|
32
|
+
metadata: Record<string, any>;
|
|
33
|
+
total_recipients: number;
|
|
34
|
+
recipients_sent: number;
|
|
35
|
+
recipients_delivered: number;
|
|
36
|
+
recipients_opened: number;
|
|
37
|
+
recipients_clicked: number;
|
|
38
|
+
recipients_bounced: number;
|
|
39
|
+
recipients_unsubscribed: number;
|
|
40
|
+
open_rate: number;
|
|
41
|
+
click_rate: number;
|
|
42
|
+
bounce_rate: number;
|
|
43
|
+
error_message: string | null;
|
|
44
|
+
created_at: string;
|
|
45
|
+
updated_at: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface MessageRecipient {
|
|
49
|
+
id: string;
|
|
50
|
+
recipient_type: string;
|
|
51
|
+
recipient_id: string | null;
|
|
52
|
+
recipient_email: string;
|
|
53
|
+
recipient_name: string | null;
|
|
54
|
+
channel_identifier: string | null;
|
|
55
|
+
channel_profiles: Record<string, any>;
|
|
56
|
+
status: RecipientStatus;
|
|
57
|
+
sent_at: string | null;
|
|
58
|
+
delivered_at: string | null;
|
|
59
|
+
bounced_at: string | null;
|
|
60
|
+
first_opened_at: string | null;
|
|
61
|
+
last_opened_at: string | null;
|
|
62
|
+
first_clicked_at: string | null;
|
|
63
|
+
last_clicked_at: string | null;
|
|
64
|
+
open_count: number;
|
|
65
|
+
click_count: number;
|
|
66
|
+
is_unsubscribed: boolean;
|
|
67
|
+
unsubscribed_at: string | null;
|
|
68
|
+
error_message: string | null;
|
|
69
|
+
created_at: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface MessagingChannel {
|
|
73
|
+
id: string;
|
|
74
|
+
key: string;
|
|
75
|
+
name: string;
|
|
76
|
+
description: string;
|
|
77
|
+
icon: string;
|
|
78
|
+
capabilities: {
|
|
79
|
+
max_content_length: number;
|
|
80
|
+
supports_html: boolean;
|
|
81
|
+
supports_markdown: boolean;
|
|
82
|
+
supports_attachments: boolean;
|
|
83
|
+
max_attachments: number;
|
|
84
|
+
max_attachment_size: number;
|
|
85
|
+
};
|
|
86
|
+
requirements: {
|
|
87
|
+
requires_connection: boolean;
|
|
88
|
+
requires_opt_in: boolean;
|
|
89
|
+
auth_requirements: Record<string, any>;
|
|
90
|
+
};
|
|
91
|
+
rate_limit: {
|
|
92
|
+
messages: number;
|
|
93
|
+
seconds: number;
|
|
94
|
+
scope: string;
|
|
95
|
+
description: string;
|
|
96
|
+
};
|
|
97
|
+
metadata: Record<string, any>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface MessageFilters {
|
|
101
|
+
status?: MessageStatus;
|
|
102
|
+
content_type?: MessageContentType;
|
|
103
|
+
scheduled_after?: string;
|
|
104
|
+
scheduled_before?: string;
|
|
105
|
+
sent_after?: string;
|
|
106
|
+
sent_before?: string;
|
|
107
|
+
search?: string;
|
|
108
|
+
page?: number;
|
|
109
|
+
page_size?: number;
|
|
110
|
+
ordering?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface CreateMessageInput {
|
|
114
|
+
channel: string;
|
|
115
|
+
subject: string;
|
|
116
|
+
body: string;
|
|
117
|
+
content_type?: MessageContentType;
|
|
118
|
+
from_name?: string;
|
|
119
|
+
from_email?: string;
|
|
120
|
+
reply_to?: string;
|
|
121
|
+
scheduled_at?: string;
|
|
122
|
+
metadata?: Record<string, any>;
|
|
123
|
+
recipients?: Array<{
|
|
124
|
+
recipient_type: string;
|
|
125
|
+
recipient_email: string;
|
|
126
|
+
recipient_name?: string;
|
|
127
|
+
channel_identifier?: string;
|
|
128
|
+
}>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface ScheduleMessageInput {
|
|
132
|
+
scheduled_at: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface SendTestInput {
|
|
136
|
+
test_email?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export class MessagesApi {
|
|
140
|
+
constructor(private client: ApiClient) {}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* List messages with optional filters
|
|
144
|
+
*/
|
|
145
|
+
async list(filters?: MessageFilters) {
|
|
146
|
+
const params = new URLSearchParams();
|
|
147
|
+
|
|
148
|
+
if (filters?.status) params.append('status', filters.status);
|
|
149
|
+
if (filters?.content_type) params.append('content_type', filters.content_type);
|
|
150
|
+
if (filters?.scheduled_after) params.append('scheduled_after', filters.scheduled_after);
|
|
151
|
+
if (filters?.scheduled_before) params.append('scheduled_before', filters.scheduled_before);
|
|
152
|
+
if (filters?.sent_after) params.append('sent_after', filters.sent_after);
|
|
153
|
+
if (filters?.sent_before) params.append('sent_before', filters.sent_before);
|
|
154
|
+
if (filters?.search) params.append('search', filters.search);
|
|
155
|
+
if (filters?.page) params.append('page', String(filters.page));
|
|
156
|
+
if (filters?.page_size) params.append('page_size', String(filters.page_size));
|
|
157
|
+
if (filters?.ordering) params.append('ordering', filters.ordering);
|
|
158
|
+
|
|
159
|
+
return this.client.get<{ results: Message[]; count: number; next: string | null; previous: string | null }>(
|
|
160
|
+
`/api/v1/messages/?${params.toString()}`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get message by ID
|
|
166
|
+
*/
|
|
167
|
+
async get(id: string) {
|
|
168
|
+
return this.client.get<Message>(`/api/v1/messages/${id}/`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Create a new message
|
|
173
|
+
*/
|
|
174
|
+
async create(data: CreateMessageInput) {
|
|
175
|
+
return this.client.post<Message>('/api/v1/messages/', data);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Update message (draft only)
|
|
180
|
+
*/
|
|
181
|
+
async update(id: string, data: Partial<CreateMessageInput>) {
|
|
182
|
+
return this.client.patch<Message>(`/api/v1/messages/${id}/`, data);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Delete message (draft only)
|
|
187
|
+
*/
|
|
188
|
+
async delete(id: string) {
|
|
189
|
+
return this.client.delete(`/api/v1/messages/${id}/`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Schedule message for future sending
|
|
194
|
+
*/
|
|
195
|
+
async schedule(id: string, input: ScheduleMessageInput) {
|
|
196
|
+
return this.client.post<{ id: string; status: MessageStatus; scheduled_at: string; message: string }>(
|
|
197
|
+
`/api/v1/messages/${id}/schedule/`,
|
|
198
|
+
input
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Send message immediately
|
|
204
|
+
*/
|
|
205
|
+
async sendNow(id: string) {
|
|
206
|
+
return this.client.post<{ id: string; status: MessageStatus; message: string }>(
|
|
207
|
+
`/api/v1/messages/${id}/send_now/`,
|
|
208
|
+
{}
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Send test message
|
|
214
|
+
*/
|
|
215
|
+
async sendTest(id: string, input?: SendTestInput) {
|
|
216
|
+
return this.client.post<{ id: string; test_email: string; message: string }>(
|
|
217
|
+
`/api/v1/messages/${id}/send_test/`,
|
|
218
|
+
input || {}
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Preview message rendering
|
|
224
|
+
*/
|
|
225
|
+
async preview(id: string) {
|
|
226
|
+
return this.client.get<{
|
|
227
|
+
subject: string;
|
|
228
|
+
body: string;
|
|
229
|
+
preview_html: string;
|
|
230
|
+
from_name: string;
|
|
231
|
+
from_email: string;
|
|
232
|
+
}>(`/api/v1/messages/${id}/preview/`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* List recipients for a message
|
|
237
|
+
*/
|
|
238
|
+
async getRecipients(id: string, page?: number, pageSize?: number) {
|
|
239
|
+
const params = new URLSearchParams();
|
|
240
|
+
if (page) params.append('page', String(page));
|
|
241
|
+
if (pageSize) params.append('page_size', String(pageSize));
|
|
242
|
+
|
|
243
|
+
return this.client.get<{ results: MessageRecipient[]; count: number }>(
|
|
244
|
+
`/api/v1/messages/${id}/recipients/?${params.toString()}`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Add recipients to a message
|
|
250
|
+
*/
|
|
251
|
+
async addRecipients(
|
|
252
|
+
id: string,
|
|
253
|
+
recipients: Array<{
|
|
254
|
+
recipient_type: string;
|
|
255
|
+
recipient_email: string;
|
|
256
|
+
recipient_name?: string;
|
|
257
|
+
channel_identifier?: string;
|
|
258
|
+
}>
|
|
259
|
+
) {
|
|
260
|
+
return this.client.post<{
|
|
261
|
+
message: string;
|
|
262
|
+
total_recipients: number;
|
|
263
|
+
recipients: MessageRecipient[];
|
|
264
|
+
}>(`/api/v1/messages/${id}/add_recipients/`, { recipients });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Get available messaging channels
|
|
269
|
+
*/
|
|
270
|
+
async getChannels() {
|
|
271
|
+
return this.client.get<{ channels: MessagingChannel[]; count: number }>('/api/v1/messages/channels/');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Organizations API wrapper for /api/v1/organizations/
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
Organization,
|
|
7
|
+
CreateOrganizationRequest,
|
|
8
|
+
UpdateOrganizationRequest,
|
|
9
|
+
OrganizationFilters,
|
|
10
|
+
PaginatedResponse,
|
|
11
|
+
PaginationParams,
|
|
12
|
+
SortParams,
|
|
13
|
+
} from '../types';
|
|
14
|
+
import { buildFilterParams, mergeQueryParams } from '../utils';
|
|
15
|
+
import { ENDPOINTS } from '../constants/endpoints';
|
|
16
|
+
import type { ApiClient } from './api-client';
|
|
17
|
+
|
|
18
|
+
export class OrganizationsApi {
|
|
19
|
+
constructor(private client: ApiClient) {}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* List organizations with pagination and filters
|
|
23
|
+
*/
|
|
24
|
+
async list(
|
|
25
|
+
filters?: OrganizationFilters,
|
|
26
|
+
pagination?: PaginationParams,
|
|
27
|
+
sorting?: SortParams
|
|
28
|
+
): Promise<PaginatedResponse<Organization>> {
|
|
29
|
+
const params = mergeQueryParams(
|
|
30
|
+
pagination,
|
|
31
|
+
sorting,
|
|
32
|
+
filters ? buildFilterParams(filters) : undefined
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return this.client.fetch.get<PaginatedResponse<Organization>>(ENDPOINTS.ORGANIZATIONS, {
|
|
36
|
+
params,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get organization by ID
|
|
42
|
+
*/
|
|
43
|
+
async get(id: string): Promise<Organization> {
|
|
44
|
+
return this.client.fetch.get<Organization>(ENDPOINTS.ORGANIZATION(id));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create new organization
|
|
49
|
+
*/
|
|
50
|
+
async create(data: CreateOrganizationRequest): Promise<Organization> {
|
|
51
|
+
return this.client.fetch.post<Organization>(ENDPOINTS.ORGANIZATIONS, data);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Update organization
|
|
56
|
+
*/
|
|
57
|
+
async update(id: string, data: UpdateOrganizationRequest): Promise<Organization> {
|
|
58
|
+
return this.client.fetch.patch<Organization>(ENDPOINTS.ORGANIZATION(id), data);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Delete organization
|
|
63
|
+
*/
|
|
64
|
+
async delete(id: string): Promise<void> {
|
|
65
|
+
return this.client.fetch.delete<void>(ENDPOINTS.ORGANIZATION(id));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Bulk create organizations
|
|
70
|
+
*/
|
|
71
|
+
async bulkCreate(data: CreateOrganizationRequest[]): Promise<Organization[]> {
|
|
72
|
+
return this.client.fetch.post<Organization[]>(ENDPOINTS.ORGANIZATIONS_BULK, data);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Search organizations by name, domain, or location
|
|
77
|
+
*/
|
|
78
|
+
async search(
|
|
79
|
+
query: string,
|
|
80
|
+
pagination?: PaginationParams
|
|
81
|
+
): Promise<PaginatedResponse<Organization>> {
|
|
82
|
+
return this.list({ search: query }, pagination);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get organizations by tier
|
|
87
|
+
*/
|
|
88
|
+
async getByTier(
|
|
89
|
+
tier: number,
|
|
90
|
+
pagination?: PaginationParams
|
|
91
|
+
): Promise<PaginatedResponse<Organization>> {
|
|
92
|
+
return this.list({ tier }, pagination);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get organizations by stage
|
|
97
|
+
*/
|
|
98
|
+
async getByStage(
|
|
99
|
+
stage: string,
|
|
100
|
+
pagination?: PaginationParams
|
|
101
|
+
): Promise<PaginatedResponse<Organization>> {
|
|
102
|
+
return this.list({ stage }, pagination);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get organizations by focus area
|
|
107
|
+
*/
|
|
108
|
+
async getByFocusArea(
|
|
109
|
+
focusArea: string,
|
|
110
|
+
pagination?: PaginationParams
|
|
111
|
+
): Promise<PaginatedResponse<Organization>> {
|
|
112
|
+
return this.list({ focusArea }, pagination);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get organizations by check size range
|
|
117
|
+
*/
|
|
118
|
+
async getByCheckSizeRange(
|
|
119
|
+
min?: number,
|
|
120
|
+
max?: number,
|
|
121
|
+
pagination?: PaginationParams
|
|
122
|
+
): Promise<PaginatedResponse<Organization>> {
|
|
123
|
+
const filters: OrganizationFilters = {};
|
|
124
|
+
if (min !== undefined) {
|
|
125
|
+
filters.checkSizeMinGte = min;
|
|
126
|
+
}
|
|
127
|
+
if (max !== undefined) {
|
|
128
|
+
filters.checkSizeMaxLte = max;
|
|
129
|
+
}
|
|
130
|
+
return this.list(filters, pagination);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory rate limiter for Next.js API routes.
|
|
3
|
+
*
|
|
4
|
+
* Fixed-window counter with periodic cleanup.
|
|
5
|
+
* Resets on server restart and does not share state across multiple instances.
|
|
6
|
+
* For multi-instance production use, back the store with Redis/Upstash instead.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface RateLimitOptions {
|
|
10
|
+
/** Duration of the rate limit window in milliseconds */
|
|
11
|
+
windowMs: number;
|
|
12
|
+
/** Maximum number of requests allowed per window */
|
|
13
|
+
maxRequests: number;
|
|
14
|
+
/** Optional prefix for keys (useful when sharing a single limiter across routes) */
|
|
15
|
+
keyPrefix?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RateLimitResult {
|
|
19
|
+
/** Whether the request is within the allowed limit */
|
|
20
|
+
success: boolean;
|
|
21
|
+
/** Requests remaining in the current window */
|
|
22
|
+
remaining: number;
|
|
23
|
+
/** Timestamp (ms since epoch) when the current window resets */
|
|
24
|
+
resetAt: number;
|
|
25
|
+
/** Seconds to wait before retrying, present only when success is false */
|
|
26
|
+
retryAfter?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface RateLimitEntry {
|
|
30
|
+
count: number;
|
|
31
|
+
resetAt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Factory that returns a rate-check function backed by an isolated in-memory store.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* const check = createRateLimiter({ windowMs: 60_000, maxRequests: 10 });
|
|
41
|
+
* const result = check('user:123');
|
|
42
|
+
* if (!result.success) { // respond 429 }
|
|
43
|
+
*/
|
|
44
|
+
export function createRateLimiter(options: RateLimitOptions): (key: string) => RateLimitResult {
|
|
45
|
+
const { windowMs, maxRequests, keyPrefix = '' } = options;
|
|
46
|
+
const store = new Map<string, RateLimitEntry>();
|
|
47
|
+
let lastCleanup = Date.now();
|
|
48
|
+
|
|
49
|
+
function cleanup(): void {
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
|
|
52
|
+
lastCleanup = now;
|
|
53
|
+
for (const [key, entry] of store.entries()) {
|
|
54
|
+
if (now > entry.resetAt) store.delete(key);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return function check(key: string): RateLimitResult {
|
|
59
|
+
cleanup();
|
|
60
|
+
|
|
61
|
+
const storeKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const entry = store.get(storeKey);
|
|
64
|
+
|
|
65
|
+
if (!entry || now > entry.resetAt) {
|
|
66
|
+
const resetAt = now + windowMs;
|
|
67
|
+
store.set(storeKey, { count: 1, resetAt });
|
|
68
|
+
return { success: true, remaining: maxRequests - 1, resetAt };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (entry.count >= maxRequests) {
|
|
72
|
+
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
|
73
|
+
return { success: false, remaining: 0, resetAt: entry.resetAt, retryAfter };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
entry.count++;
|
|
77
|
+
return { success: true, remaining: maxRequests - entry.count, resetAt: entry.resetAt };
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Extract the client IP address from a Web API Request or NextRequest.
|
|
83
|
+
* Respects x-forwarded-for and x-real-ip proxy headers.
|
|
84
|
+
*/
|
|
85
|
+
export function getClientIP(request: Request): string {
|
|
86
|
+
const forwarded = request.headers.get('x-forwarded-for');
|
|
87
|
+
if (forwarded) return forwarded.split(',')[0].trim();
|
|
88
|
+
const real = request.headers.get('x-real-ip');
|
|
89
|
+
if (real) return real;
|
|
90
|
+
return 'unknown';
|
|
91
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import DOMPurify from 'isomorphic-dompurify';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* XSS Protection - sanitize HTML content
|
|
5
|
+
*
|
|
6
|
+
* Strips all tags except a safe allowlist and removes dangerous attributes.
|
|
7
|
+
*/
|
|
8
|
+
export function sanitizeHtml(input: string): string {
|
|
9
|
+
return DOMPurify.sanitize(input, {
|
|
10
|
+
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
|
|
11
|
+
ALLOWED_ATTR: ['href'],
|
|
12
|
+
ALLOW_DATA_ATTR: false,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Validate and sanitize a search query string.
|
|
18
|
+
*
|
|
19
|
+
* Trims whitespace, escapes regex special characters, and limits length to 100 chars.
|
|
20
|
+
*/
|
|
21
|
+
export function sanitizeSearchQuery(query: string): string {
|
|
22
|
+
if (!query || typeof query !== 'string') {
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return query
|
|
27
|
+
.trim()
|
|
28
|
+
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape regex special chars
|
|
29
|
+
.substring(0, 100); // Limit length
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate that a string is a safe SQL/API identifier.
|
|
34
|
+
*
|
|
35
|
+
* Only allows alphanumeric characters, underscores, and hyphens.
|
|
36
|
+
*/
|
|
37
|
+
export function validateIdentifier(input: string): boolean {
|
|
38
|
+
return /^[a-zA-Z0-9_-]+$/.test(input);
|
|
39
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflows API wrapper
|
|
3
|
+
*
|
|
4
|
+
* Provides type-safe access to Django Workflows API
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ApiClient } from './api-client';
|
|
8
|
+
|
|
9
|
+
export type WorkflowStatus = 'draft' | 'active' | 'paused';
|
|
10
|
+
|
|
11
|
+
export interface Workflow {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
team: string;
|
|
16
|
+
createdBy: string | null;
|
|
17
|
+
nodes: any[];
|
|
18
|
+
connections: Record<string, any>;
|
|
19
|
+
settings: Record<string, any>;
|
|
20
|
+
staticData: Record<string, any>;
|
|
21
|
+
isActive: boolean;
|
|
22
|
+
version: number;
|
|
23
|
+
isTemplate: boolean;
|
|
24
|
+
templateSource: string | null;
|
|
25
|
+
executionsCount: number;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
updatedAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface WorkflowExecution {
|
|
31
|
+
id: string;
|
|
32
|
+
workflow: string;
|
|
33
|
+
workflowVersion: number;
|
|
34
|
+
triggeredBy: string;
|
|
35
|
+
status: 'pending' | 'running' | 'completed' | 'failed' | 'paused';
|
|
36
|
+
mode: 'manual' | 'trigger' | 'webhook';
|
|
37
|
+
contextData: Record<string, any>;
|
|
38
|
+
state: Record<string, any>;
|
|
39
|
+
startedAt: string | null;
|
|
40
|
+
completedAt: string | null;
|
|
41
|
+
waitUntil: string | null;
|
|
42
|
+
errorMessage: string | null;
|
|
43
|
+
createdAt: string;
|
|
44
|
+
updatedAt: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WorkflowFilters {
|
|
48
|
+
team?: string;
|
|
49
|
+
isActive?: boolean;
|
|
50
|
+
isTemplate?: boolean;
|
|
51
|
+
page?: number;
|
|
52
|
+
pageSize?: number;
|
|
53
|
+
ordering?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ExecuteWorkflowInput {
|
|
57
|
+
contextData?: Record<string, any>;
|
|
58
|
+
mode?: 'manual' | 'trigger' | 'webhook';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CreateWorkflowInput {
|
|
62
|
+
name: string;
|
|
63
|
+
description?: string;
|
|
64
|
+
team: string;
|
|
65
|
+
nodes?: any[];
|
|
66
|
+
connections?: Record<string, any>;
|
|
67
|
+
settings?: Record<string, any>;
|
|
68
|
+
isActive?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class WorkflowsApi {
|
|
72
|
+
constructor(private client: ApiClient) {}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* List workflows with optional filters
|
|
76
|
+
*/
|
|
77
|
+
async list(filters?: WorkflowFilters) {
|
|
78
|
+
const params = new URLSearchParams();
|
|
79
|
+
|
|
80
|
+
if (filters?.team) params.append('team', filters.team);
|
|
81
|
+
if (filters?.isActive !== undefined) params.append('isActive', String(filters.isActive));
|
|
82
|
+
if (filters?.isTemplate !== undefined) params.append('isTemplate', String(filters.isTemplate));
|
|
83
|
+
if (filters?.page) params.append('page', String(filters.page));
|
|
84
|
+
if (filters?.pageSize) params.append('pageSize', String(filters.pageSize));
|
|
85
|
+
if (filters?.ordering) params.append('ordering', filters.ordering);
|
|
86
|
+
|
|
87
|
+
return this.client.get<{ results: Workflow[]; count: number; next: string | null; previous: string | null }>(
|
|
88
|
+
`/api/v1/workflows/?${params.toString()}`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get workflow by ID
|
|
94
|
+
*/
|
|
95
|
+
async get(id: string) {
|
|
96
|
+
return this.client.get<Workflow>(`/api/v1/workflows/${id}/`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Create a new workflow
|
|
101
|
+
*/
|
|
102
|
+
async create(data: CreateWorkflowInput) {
|
|
103
|
+
return this.client.post<Workflow>('/api/v1/workflows/', data);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Update workflow
|
|
108
|
+
*/
|
|
109
|
+
async update(id: string, data: Partial<CreateWorkflowInput>) {
|
|
110
|
+
return this.client.patch<Workflow>(`/api/v1/workflows/${id}/`, data);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Delete workflow
|
|
115
|
+
*/
|
|
116
|
+
async delete(id: string) {
|
|
117
|
+
return this.client.delete(`/api/v1/workflows/${id}/`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Execute workflow
|
|
122
|
+
*/
|
|
123
|
+
async execute(id: string, input?: ExecuteWorkflowInput) {
|
|
124
|
+
return this.client.post<WorkflowExecution>(
|
|
125
|
+
`/api/v1/workflows/${id}/execute/`,
|
|
126
|
+
input || {}
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get workflow templates
|
|
132
|
+
*/
|
|
133
|
+
async templates() {
|
|
134
|
+
return this.client.get<Workflow[]>('/api/v1/workflows/templates/');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* List workflow executions
|
|
139
|
+
*/
|
|
140
|
+
async listExecutions(filters?: { workflow?: string; status?: string; page?: number; pageSize?: number }) {
|
|
141
|
+
const params = new URLSearchParams();
|
|
142
|
+
|
|
143
|
+
if (filters?.workflow) params.append('workflow', filters.workflow);
|
|
144
|
+
if (filters?.status) params.append('status', filters.status);
|
|
145
|
+
if (filters?.page) params.append('page', String(filters.page));
|
|
146
|
+
if (filters?.pageSize) params.append('pageSize', String(filters.pageSize));
|
|
147
|
+
|
|
148
|
+
return this.client.get<{ results: WorkflowExecution[]; count: number }>(
|
|
149
|
+
`/api/v1/workflow-executions/?${params.toString()}`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get workflow execution details
|
|
155
|
+
*/
|
|
156
|
+
async getExecution(id: string) {
|
|
157
|
+
return this.client.get<WorkflowExecution>(`/api/v1/workflow-executions/${id}/`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware exports for @startsimpli/api
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { withAuth, extractUserIdFromToken } from './with-auth';
|
|
6
|
+
export type { AuthContext, ApiHandler, WithAuthOptions } from './with-auth';
|
|
7
|
+
|
|
8
|
+
export { withErrorHandling } from './with-error-handling';
|
|
9
|
+
export type { ErrorApiHandler, ErrorResponseBody } from './with-error-handling';
|
|
10
|
+
|
|
11
|
+
export { withValidation, composeMiddleware } from './with-validation';
|
|
12
|
+
export type { ValidatedApiHandler, ValidationSchemas } from './with-validation';
|