@unedio/types 0.0.1
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/package.json +18 -0
- package/src/types/api.types.ts +423 -0
- package/src/types/database.types.ts +375 -0
- package/src/types/enums.ts +62 -0
- package/src/types/hono.types.ts +32 -0
- package/src/utils/ai.ts +70 -0
- package/src/utils/email.ts +26 -0
- package/src/utils/errors.ts +60 -0
- package/src/utils/json.ts +26 -0
- package/src/utils/pagination.ts +44 -0
- package/src/utils/storage.ts +57 -0
- package/src/utils/stripe.ts +240 -0
- package/src/validators/application.validator.ts +61 -0
- package/src/validators/candidate.validator.ts +52 -0
- package/src/validators/client.validator.ts +29 -0
- package/src/validators/common.validator.ts +16 -0
- package/src/validators/contact.validator.ts +29 -0
- package/src/validators/email-template.validator.ts +28 -0
- package/src/validators/job.validator.ts +61 -0
- package/src/validators/parse-cv.validator.ts +28 -0
- package/src/validators/saved-candidate.validator.ts +27 -0
- package/src/validators/seeker.validator.ts +29 -0
- package/src/validators/subscription.validator.ts +21 -0
- package/src/validators/user.validator.ts +86 -0
- package/src/validators/waitlist.validator.ts +8 -0
- package/src/validators/workspace.validator.ts +93 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database types matching the SQL schema
|
|
3
|
+
* These types represent the structure of tables in Supabase
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
ApplicationSource,
|
|
8
|
+
ChangeType,
|
|
9
|
+
CustomDomainStatus,
|
|
10
|
+
EmailTemplateType,
|
|
11
|
+
JobStatus,
|
|
12
|
+
JobType,
|
|
13
|
+
SubscriptionStatus,
|
|
14
|
+
UserType,
|
|
15
|
+
WorkModality,
|
|
16
|
+
WorkspaceUserRole,
|
|
17
|
+
} from './enums';
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// USERS & AUTHENTICATION
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
export interface User {
|
|
24
|
+
id: string;
|
|
25
|
+
external_id: string;
|
|
26
|
+
email: string;
|
|
27
|
+
name: string | null;
|
|
28
|
+
type: UserType;
|
|
29
|
+
avatar_url: string | null;
|
|
30
|
+
created_at: string;
|
|
31
|
+
updated_at: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SeekerProfile {
|
|
35
|
+
id: string;
|
|
36
|
+
user_id: string;
|
|
37
|
+
full_name: string | null;
|
|
38
|
+
phone: string | null;
|
|
39
|
+
linkedin_url: string | null;
|
|
40
|
+
city: string | null;
|
|
41
|
+
country: string | null;
|
|
42
|
+
summary: string | null;
|
|
43
|
+
skills: string[] | null;
|
|
44
|
+
experience_years: number | null;
|
|
45
|
+
education: Education[] | null;
|
|
46
|
+
experience: Experience[] | null;
|
|
47
|
+
updated_at: string;
|
|
48
|
+
cv_url: string | null;
|
|
49
|
+
email: string | null;
|
|
50
|
+
availability_status: 'available' | 'hired' | 'not_looking';
|
|
51
|
+
last_placed_at_client_id: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Education {
|
|
55
|
+
institution: string;
|
|
56
|
+
degree: string;
|
|
57
|
+
field: string | null;
|
|
58
|
+
start_date: string;
|
|
59
|
+
end_date: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface Experience {
|
|
63
|
+
company: string;
|
|
64
|
+
position: string;
|
|
65
|
+
description: string;
|
|
66
|
+
start_date: string;
|
|
67
|
+
end_date: string | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ============================================================================
|
|
71
|
+
// WORKSPACES & MULTI-TENANCY
|
|
72
|
+
// ============================================================================
|
|
73
|
+
|
|
74
|
+
export interface Workspace {
|
|
75
|
+
id: string;
|
|
76
|
+
name: string;
|
|
77
|
+
domain: string | null;
|
|
78
|
+
subdomain: string | null;
|
|
79
|
+
custom_domain_status: CustomDomainStatus;
|
|
80
|
+
created_at: string;
|
|
81
|
+
deleted_at: string | null;
|
|
82
|
+
workspace_mode: 'company' | 'agency';
|
|
83
|
+
language: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface WorkspaceUser {
|
|
87
|
+
id: string;
|
|
88
|
+
workspace_id: string;
|
|
89
|
+
user_id: string;
|
|
90
|
+
role: WorkspaceUserRole;
|
|
91
|
+
created_at: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface WorkspaceBranding {
|
|
95
|
+
id: string;
|
|
96
|
+
workspace_id: string;
|
|
97
|
+
primary_color: string;
|
|
98
|
+
secondary_color: string;
|
|
99
|
+
font_family: string;
|
|
100
|
+
logo_url: string | null;
|
|
101
|
+
favicon_url: string | null;
|
|
102
|
+
updated_at: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ============================================================================
|
|
106
|
+
// CLIENTS & CONTACTS
|
|
107
|
+
// ============================================================================
|
|
108
|
+
|
|
109
|
+
export interface Client {
|
|
110
|
+
id: string;
|
|
111
|
+
workspace_id: string;
|
|
112
|
+
name: string;
|
|
113
|
+
website: string | null;
|
|
114
|
+
industry: string | null;
|
|
115
|
+
created_at: string;
|
|
116
|
+
deleted_at: string | null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface Note {
|
|
120
|
+
id: string;
|
|
121
|
+
workspace_id: string;
|
|
122
|
+
seeker_id: string | null;
|
|
123
|
+
job_id: string | null;
|
|
124
|
+
client_id: string | null;
|
|
125
|
+
contact_id: string | null;
|
|
126
|
+
author_id: string | null;
|
|
127
|
+
content: string;
|
|
128
|
+
created_at: string;
|
|
129
|
+
updated_at: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface Contact {
|
|
133
|
+
id: string;
|
|
134
|
+
workspace_id: string;
|
|
135
|
+
client_id: string;
|
|
136
|
+
full_name: string;
|
|
137
|
+
email: string | null;
|
|
138
|
+
phone: string | null;
|
|
139
|
+
role: string | null;
|
|
140
|
+
is_primary: boolean;
|
|
141
|
+
created_at: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ============================================================================
|
|
145
|
+
// JOBS & APPLICATIONS
|
|
146
|
+
// ============================================================================
|
|
147
|
+
|
|
148
|
+
export interface Job {
|
|
149
|
+
id: string;
|
|
150
|
+
workspace_id: string;
|
|
151
|
+
recruiter_id: string | null;
|
|
152
|
+
title: string;
|
|
153
|
+
description: string;
|
|
154
|
+
work_modality: WorkModality | null;
|
|
155
|
+
job_type: JobType | null;
|
|
156
|
+
status: JobStatus;
|
|
157
|
+
location: string | null;
|
|
158
|
+
salary_range: string | null;
|
|
159
|
+
created_at: string;
|
|
160
|
+
deleted_at: string | null;
|
|
161
|
+
client_id: string | null;
|
|
162
|
+
contact_id: string | null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface JobViewStats {
|
|
166
|
+
id: string;
|
|
167
|
+
job_id: string;
|
|
168
|
+
source: ApplicationSource;
|
|
169
|
+
created_at: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface ApplicationStatus {
|
|
173
|
+
id: string;
|
|
174
|
+
workspace_id: string;
|
|
175
|
+
status_name: string;
|
|
176
|
+
order: number;
|
|
177
|
+
created_at: string;
|
|
178
|
+
updated_at: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface JobApplication {
|
|
182
|
+
id: string;
|
|
183
|
+
job_id: string;
|
|
184
|
+
user_id: string;
|
|
185
|
+
status_id: string | null;
|
|
186
|
+
source: ApplicationSource;
|
|
187
|
+
applied_at: string;
|
|
188
|
+
updated_at: string;
|
|
189
|
+
created_by_id: string | null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface JobApplicationComment {
|
|
193
|
+
id: string;
|
|
194
|
+
job_application_id: string;
|
|
195
|
+
user_id: string;
|
|
196
|
+
comment_text: string;
|
|
197
|
+
created_at: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface JobApplicationHistory {
|
|
201
|
+
id: string;
|
|
202
|
+
job_application_id: string;
|
|
203
|
+
actor_id: string | null;
|
|
204
|
+
change_type: ChangeType;
|
|
205
|
+
old_value: string | null;
|
|
206
|
+
new_value: string | null;
|
|
207
|
+
created_at: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ============================================================================
|
|
211
|
+
// SUBSCRIPTIONS & BILLING
|
|
212
|
+
// ============================================================================
|
|
213
|
+
|
|
214
|
+
export interface Plan {
|
|
215
|
+
id: string;
|
|
216
|
+
name: string;
|
|
217
|
+
slug: string;
|
|
218
|
+
max_seats: number;
|
|
219
|
+
price_id_stripe: string | null;
|
|
220
|
+
created_at: string;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface Subscription {
|
|
224
|
+
id: string;
|
|
225
|
+
workspace_id: string;
|
|
226
|
+
plan_id: string;
|
|
227
|
+
stripe_subscription_id: string | null;
|
|
228
|
+
status: SubscriptionStatus;
|
|
229
|
+
current_period_end: string | null;
|
|
230
|
+
created_at: string;
|
|
231
|
+
updated_at: string;
|
|
232
|
+
stripe_customer_id: string | null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ============================================================================
|
|
236
|
+
// COMMUNICATIONS
|
|
237
|
+
// ============================================================================
|
|
238
|
+
|
|
239
|
+
export interface EmailTemplate {
|
|
240
|
+
id: string;
|
|
241
|
+
workspace_id: string;
|
|
242
|
+
name: string;
|
|
243
|
+
subject: string;
|
|
244
|
+
body_text: string;
|
|
245
|
+
type: EmailTemplateType;
|
|
246
|
+
created_at: string;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface SavedCandidate {
|
|
250
|
+
id: string;
|
|
251
|
+
workspace_id: string;
|
|
252
|
+
seeker_id: string;
|
|
253
|
+
added_by: string | null;
|
|
254
|
+
created_at: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ============================================================================
|
|
258
|
+
// OTHER
|
|
259
|
+
// ============================================================================
|
|
260
|
+
|
|
261
|
+
export interface Waitlist {
|
|
262
|
+
id: string;
|
|
263
|
+
email: string;
|
|
264
|
+
created_at: string;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ============================================================================
|
|
268
|
+
// DATABASE TYPES (for Supabase client)
|
|
269
|
+
// ============================================================================
|
|
270
|
+
|
|
271
|
+
export interface Database {
|
|
272
|
+
public: {
|
|
273
|
+
Tables: {
|
|
274
|
+
users: {
|
|
275
|
+
Row: User;
|
|
276
|
+
Insert: Omit<User, 'id' | 'created_at' | 'updated_at'>;
|
|
277
|
+
Update: Partial<Omit<User, 'id' | 'external_id' | 'created_at'>>;
|
|
278
|
+
};
|
|
279
|
+
seeker_profiles: {
|
|
280
|
+
Row: SeekerProfile;
|
|
281
|
+
Insert: Omit<SeekerProfile, 'id' | 'updated_at'>;
|
|
282
|
+
Update: Partial<Omit<SeekerProfile, 'id' | 'user_id'>>;
|
|
283
|
+
};
|
|
284
|
+
workspaces: {
|
|
285
|
+
Row: Workspace;
|
|
286
|
+
Insert: Omit<Workspace, 'id' | 'created_at' | 'deleted_at' | 'language'> & {
|
|
287
|
+
language?: string;
|
|
288
|
+
};
|
|
289
|
+
Update: Partial<Omit<Workspace, 'id' | 'created_at'>>;
|
|
290
|
+
};
|
|
291
|
+
workspace_users: {
|
|
292
|
+
Row: WorkspaceUser;
|
|
293
|
+
Insert: Omit<WorkspaceUser, 'id' | 'created_at'>;
|
|
294
|
+
Update: Partial<Omit<WorkspaceUser, 'id' | 'workspace_id' | 'user_id' | 'created_at'>>;
|
|
295
|
+
};
|
|
296
|
+
workspace_branding: {
|
|
297
|
+
Row: WorkspaceBranding;
|
|
298
|
+
Insert: Omit<WorkspaceBranding, 'id' | 'updated_at'>;
|
|
299
|
+
Update: Partial<Omit<WorkspaceBranding, 'id' | 'workspace_id'>>;
|
|
300
|
+
};
|
|
301
|
+
clients: {
|
|
302
|
+
Row: Client;
|
|
303
|
+
Insert: Omit<Client, 'id' | 'created_at' | 'deleted_at'>;
|
|
304
|
+
Update: Partial<Omit<Client, 'id' | 'workspace_id' | 'created_at'>>;
|
|
305
|
+
};
|
|
306
|
+
notes: {
|
|
307
|
+
Row: Note;
|
|
308
|
+
Insert: Omit<Note, 'id' | 'created_at' | 'updated_at'>;
|
|
309
|
+
Update: Partial<Omit<Note, 'id' | 'workspace_id' | 'created_at'>>;
|
|
310
|
+
};
|
|
311
|
+
contacts: {
|
|
312
|
+
Row: Contact;
|
|
313
|
+
Insert: Omit<Contact, 'id' | 'created_at'>;
|
|
314
|
+
Update: Partial<Omit<Contact, 'id' | 'client_id' | 'created_at'>>;
|
|
315
|
+
};
|
|
316
|
+
jobs: {
|
|
317
|
+
Row: Job;
|
|
318
|
+
Insert: Omit<Job, 'id' | 'created_at' | 'deleted_at'>;
|
|
319
|
+
Update: Partial<Omit<Job, 'id' | 'workspace_id' | 'created_at'>>;
|
|
320
|
+
};
|
|
321
|
+
job_views_stats: {
|
|
322
|
+
Row: JobViewStats;
|
|
323
|
+
Insert: Omit<JobViewStats, 'id' | 'created_at'>;
|
|
324
|
+
Update: never; // Read-only stats
|
|
325
|
+
};
|
|
326
|
+
application_statuses: {
|
|
327
|
+
Row: ApplicationStatus;
|
|
328
|
+
Insert: Omit<ApplicationStatus, 'id' | 'created_at' | 'updated_at'>;
|
|
329
|
+
Update: Partial<Omit<ApplicationStatus, 'id' | 'workspace_id' | 'created_at'>>;
|
|
330
|
+
};
|
|
331
|
+
job_applications: {
|
|
332
|
+
Row: JobApplication;
|
|
333
|
+
Insert: Omit<JobApplication, 'id' | 'applied_at' | 'updated_at'>;
|
|
334
|
+
Update: Partial<Omit<JobApplication, 'id' | 'job_id' | 'user_id' | 'applied_at'>>;
|
|
335
|
+
};
|
|
336
|
+
job_application_comments: {
|
|
337
|
+
Row: JobApplicationComment;
|
|
338
|
+
Insert: Omit<JobApplicationComment, 'id' | 'created_at'>;
|
|
339
|
+
Update: Partial<
|
|
340
|
+
Omit<JobApplicationComment, 'id' | 'job_application_id' | 'user_id' | 'created_at'>
|
|
341
|
+
>;
|
|
342
|
+
};
|
|
343
|
+
job_application_history: {
|
|
344
|
+
Row: JobApplicationHistory;
|
|
345
|
+
Insert: Omit<JobApplicationHistory, 'id' | 'created_at'>;
|
|
346
|
+
Update: never; // Immutable audit log
|
|
347
|
+
};
|
|
348
|
+
plans: {
|
|
349
|
+
Row: Plan;
|
|
350
|
+
Insert: Omit<Plan, 'id' | 'created_at'>;
|
|
351
|
+
Update: Partial<Omit<Plan, 'id' | 'slug' | 'created_at'>>;
|
|
352
|
+
};
|
|
353
|
+
subscriptions: {
|
|
354
|
+
Row: Subscription;
|
|
355
|
+
Insert: Omit<Subscription, 'id' | 'created_at' | 'updated_at'>;
|
|
356
|
+
Update: Partial<Omit<Subscription, 'id' | 'workspace_id' | 'created_at'>>;
|
|
357
|
+
};
|
|
358
|
+
email_templates: {
|
|
359
|
+
Row: EmailTemplate;
|
|
360
|
+
Insert: Omit<EmailTemplate, 'id' | 'created_at'>;
|
|
361
|
+
Update: Partial<Omit<EmailTemplate, 'id' | 'workspace_id' | 'created_at'>>;
|
|
362
|
+
};
|
|
363
|
+
saved_candidates: {
|
|
364
|
+
Row: SavedCandidate;
|
|
365
|
+
Insert: Omit<SavedCandidate, 'id' | 'created_at'>;
|
|
366
|
+
Update: Partial<Omit<SavedCandidate, 'id' | 'workspace_id' | 'seeker_id' | 'created_at'>>;
|
|
367
|
+
};
|
|
368
|
+
waitlist: {
|
|
369
|
+
Row: Waitlist;
|
|
370
|
+
Insert: Omit<Waitlist, 'id' | 'created_at'>;
|
|
371
|
+
Update: never; // Insert-only table
|
|
372
|
+
};
|
|
373
|
+
};
|
|
374
|
+
};
|
|
375
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enums and constant types for the API
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export enum ErrorCode {
|
|
6
|
+
VALIDATION_ERROR = 'VALIDATION_ERROR',
|
|
7
|
+
BAD_REQUEST = 'BAD_REQUEST',
|
|
8
|
+
UNAUTHORIZED = 'UNAUTHORIZED',
|
|
9
|
+
FORBIDDEN = 'FORBIDDEN',
|
|
10
|
+
NOT_FOUND = 'NOT_FOUND',
|
|
11
|
+
CONFLICT = 'CONFLICT',
|
|
12
|
+
INTERNAL_ERROR = 'INTERNAL_ERROR',
|
|
13
|
+
DB_ERROR = 'DB_ERROR',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type UserType = 'recruiter' | 'seeker';
|
|
17
|
+
|
|
18
|
+
export type JobStatus = 'draft' | 'published' | 'archived';
|
|
19
|
+
|
|
20
|
+
export type WorkModality = 'remote' | 'hybrid' | 'office';
|
|
21
|
+
|
|
22
|
+
export type JobType = 'full-time' | 'part-time' | 'contractor' | 'internship';
|
|
23
|
+
|
|
24
|
+
export type SubscriptionStatus = 'active' | 'past_due' | 'canceled' | 'trialing';
|
|
25
|
+
|
|
26
|
+
export type CustomDomainStatus = 'not_requested' | 'pending' | 'active' | 'error';
|
|
27
|
+
|
|
28
|
+
export type EmailTemplateType =
|
|
29
|
+
| 'rejection'
|
|
30
|
+
| 'interview_invite'
|
|
31
|
+
| 'offer'
|
|
32
|
+
| 'onboarding'
|
|
33
|
+
| 'general';
|
|
34
|
+
|
|
35
|
+
export type WorkspaceUserRole = 'owner' | 'member';
|
|
36
|
+
|
|
37
|
+
export type ApplicationSource = 'direct' | 'linkedin' | 'indeed' | 'referral' | 'other';
|
|
38
|
+
|
|
39
|
+
export type ChangeType =
|
|
40
|
+
| 'status_changed'
|
|
41
|
+
| 'comment_added'
|
|
42
|
+
| 'email_sent'
|
|
43
|
+
| 'application_created'
|
|
44
|
+
| 'application_updated';
|
|
45
|
+
|
|
46
|
+
export const USER_TYPES: UserType[] = ['recruiter', 'seeker'];
|
|
47
|
+
export const JOB_STATUSES: JobStatus[] = ['draft', 'published', 'archived'];
|
|
48
|
+
export const WORK_MODALITIES: WorkModality[] = ['remote', 'hybrid', 'office'];
|
|
49
|
+
export const JOB_TYPES: JobType[] = ['full-time', 'part-time', 'contractor', 'internship'];
|
|
50
|
+
export const SUBSCRIPTION_STATUSES: SubscriptionStatus[] = [
|
|
51
|
+
'active',
|
|
52
|
+
'past_due',
|
|
53
|
+
'canceled',
|
|
54
|
+
'trialing',
|
|
55
|
+
];
|
|
56
|
+
export const EMAIL_TEMPLATE_TYPES: EmailTemplateType[] = [
|
|
57
|
+
'rejection',
|
|
58
|
+
'interview_invite',
|
|
59
|
+
'offer',
|
|
60
|
+
'onboarding',
|
|
61
|
+
'general',
|
|
62
|
+
];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
import { WorkspaceAccessState } from './api.types';
|
|
3
|
+
import { Workspace } from './database.types';
|
|
4
|
+
import { UserType, WorkspaceUserRole } from './enums';
|
|
5
|
+
|
|
6
|
+
export interface HonoEnv {
|
|
7
|
+
Bindings: {
|
|
8
|
+
PRODUCTION: string;
|
|
9
|
+
COOKIE_SECRET: string;
|
|
10
|
+
SUPABASE_URL: string;
|
|
11
|
+
SUPABASE_SECRET_KEY: string;
|
|
12
|
+
AUTH0_DOMAIN: string;
|
|
13
|
+
AUTH0_CLIENT_ID: string;
|
|
14
|
+
AUTH0_CLIENT_SECRET: string;
|
|
15
|
+
GOOGLE_AI_API_KEY: string;
|
|
16
|
+
GOOGLE_AI_MODEL?: string;
|
|
17
|
+
};
|
|
18
|
+
Variables: {
|
|
19
|
+
supabase: SupabaseClient;
|
|
20
|
+
user: {
|
|
21
|
+
idToken: string;
|
|
22
|
+
accessToken: string;
|
|
23
|
+
};
|
|
24
|
+
userId: string;
|
|
25
|
+
userType: UserType;
|
|
26
|
+
validated: any;
|
|
27
|
+
workspaceId: string;
|
|
28
|
+
workspaceRole: WorkspaceUserRole | null;
|
|
29
|
+
workspace: Workspace & { role?: WorkspaceUserRole | null };
|
|
30
|
+
workspaceAccess: WorkspaceAccessState;
|
|
31
|
+
};
|
|
32
|
+
}
|
package/src/utils/ai.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
2
|
+
|
|
3
|
+
export interface AIRequestOptions {
|
|
4
|
+
model?: string;
|
|
5
|
+
temperature?: number;
|
|
6
|
+
maxTokens?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AIResponse {
|
|
10
|
+
content: string;
|
|
11
|
+
raw: any;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_MODEL = 'gemini-3.1-flash-lite-preview';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Sends a prompt to the AI provider with retry logic for rate limits.
|
|
18
|
+
*/
|
|
19
|
+
export const sendPrompt = async (
|
|
20
|
+
apiKey: string,
|
|
21
|
+
prompt: string,
|
|
22
|
+
options: AIRequestOptions = {},
|
|
23
|
+
maxRetries = 5,
|
|
24
|
+
): Promise<AIResponse> => {
|
|
25
|
+
let lastError: any;
|
|
26
|
+
|
|
27
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
28
|
+
try {
|
|
29
|
+
const genAI = new GoogleGenerativeAI(apiKey);
|
|
30
|
+
const modelName = options.model || DEFAULT_MODEL;
|
|
31
|
+
const model = genAI.getGenerativeModel({ model: modelName });
|
|
32
|
+
|
|
33
|
+
console.log(
|
|
34
|
+
`[AI] Sending prompt to Google AI Studio using model: ${modelName} (Attempt ${attempt + 1}/${maxRetries + 1})`,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const generationConfig = {
|
|
38
|
+
temperature: options.temperature ?? 0.1,
|
|
39
|
+
maxOutputTokens: options.maxTokens ?? 8192,
|
|
40
|
+
responseMimeType: 'application/json',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const result = await model.generateContent({
|
|
44
|
+
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
|
45
|
+
generationConfig,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const response = result.response;
|
|
49
|
+
return {
|
|
50
|
+
content: response.text(),
|
|
51
|
+
raw: response,
|
|
52
|
+
};
|
|
53
|
+
} catch (error: any) {
|
|
54
|
+
lastError = error;
|
|
55
|
+
|
|
56
|
+
// Handle Rate Limit (429) for Google
|
|
57
|
+
if (error?.status === 429 && attempt < maxRetries) {
|
|
58
|
+
// Longer delay to respect Google's quota (often 30s-60s on free tier errors)
|
|
59
|
+
const delay = (attempt + 1) * 10000; // 10s, 20s, 30s, 40s...
|
|
60
|
+
console.warn(`[AI] Rate limit hit (429). Retrying in ${delay}ms...`);
|
|
61
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
throw lastError;
|
|
70
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const EMAIL_REGEX =
|
|
2
|
+
/^[\p{L}\p{N}\p{M}!#$%&'*+/=?^_`{|}~.-]+@(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,61}[\p{L}\p{N}])?\.)+[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,61}[\p{L}\p{N}])?$/u;
|
|
3
|
+
|
|
4
|
+
function compactEmailSeparators(value: string): string {
|
|
5
|
+
return value.replace(/[\u00A0\u200B-\u200D\uFEFF]/gu, '').replace(/\s*@\s*/gu, '@');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function normalizeEmail(value: string | null | undefined): string | null {
|
|
9
|
+
const trimmed = value?.normalize('NFC').trim();
|
|
10
|
+
|
|
11
|
+
if (!trimmed) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return compactEmailSeparators(trimmed).toLowerCase();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isValidEmail(value: string | null | undefined): boolean {
|
|
19
|
+
const normalized = normalizeEmail(value);
|
|
20
|
+
|
|
21
|
+
if (!normalized) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return EMAIL_REGEX.test(normalized);
|
|
26
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { StatusCode } from 'hono/utils/http-status';
|
|
2
|
+
import { ErrorCode } from '../types/enums';
|
|
3
|
+
|
|
4
|
+
export class AppError extends Error {
|
|
5
|
+
public readonly statusCode: StatusCode;
|
|
6
|
+
public readonly code: string;
|
|
7
|
+
public readonly details?: any;
|
|
8
|
+
public readonly isOperational: boolean;
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
message: string,
|
|
12
|
+
statusCode: StatusCode = 500,
|
|
13
|
+
code: string = ErrorCode.INTERNAL_ERROR,
|
|
14
|
+
details?: any,
|
|
15
|
+
) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.statusCode = statusCode;
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.details = details;
|
|
20
|
+
this.isOperational = true;
|
|
21
|
+
|
|
22
|
+
Error.captureStackTrace(this, this.constructor);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class BadRequestError extends AppError {
|
|
27
|
+
constructor(message = 'Bad Request', details?: any) {
|
|
28
|
+
super(message, 400, ErrorCode.BAD_REQUEST, details);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class UnauthorizedError extends AppError {
|
|
33
|
+
constructor(message = 'Unauthorized', details?: any) {
|
|
34
|
+
super(message, 401, ErrorCode.UNAUTHORIZED, details);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class ForbiddenError extends AppError {
|
|
39
|
+
constructor(message = 'Forbidden', details?: any) {
|
|
40
|
+
super(message, 403, ErrorCode.FORBIDDEN, details);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class NotFoundError extends AppError {
|
|
45
|
+
constructor(message = 'Not Found', details?: any) {
|
|
46
|
+
super(message, 404, ErrorCode.NOT_FOUND, details);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class ConflictError extends AppError {
|
|
51
|
+
constructor(message = 'Conflict', details?: any) {
|
|
52
|
+
super(message, 409, ErrorCode.CONFLICT, details);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class InternalServerError extends AppError {
|
|
57
|
+
constructor(message = 'Internal Server Error', details?: any) {
|
|
58
|
+
super(message, 500, ErrorCode.INTERNAL_ERROR, details);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracts and parses JSON from a string, supporting markdown code blocks.
|
|
3
|
+
*/
|
|
4
|
+
export const extractJSON = <T = any>(input: string): T => {
|
|
5
|
+
let jsonString = input.trim();
|
|
6
|
+
|
|
7
|
+
// Extract markdown code blocks if present (e.g., ```json ... ``` or just ``` ... ```)
|
|
8
|
+
const codeBlockMatch = input.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
9
|
+
if (codeBlockMatch) {
|
|
10
|
+
jsonString = codeBlockMatch[1].trim();
|
|
11
|
+
} else {
|
|
12
|
+
// Extract direct JSON object if no code blocks are found
|
|
13
|
+
const jsonMatch = input.match(/\{[\s\S]*\}/);
|
|
14
|
+
if (jsonMatch) {
|
|
15
|
+
jsonString = jsonMatch[0];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(jsonString) as T;
|
|
21
|
+
} catch (parseError) {
|
|
22
|
+
console.error('Error parsing JSON string:', parseError);
|
|
23
|
+
console.error('Original string:', input);
|
|
24
|
+
throw new Error('Failed to parse JSON content');
|
|
25
|
+
}
|
|
26
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pagination utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { PaginationMeta, PaginationParams } from '../types/api.types';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_PAGE = 1;
|
|
8
|
+
export const DEFAULT_LIMIT = 20;
|
|
9
|
+
export const MAX_LIMIT = 100;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse and validate pagination parameters
|
|
13
|
+
*/
|
|
14
|
+
export function parsePagination(params: PaginationParams): {
|
|
15
|
+
page: number;
|
|
16
|
+
limit: number;
|
|
17
|
+
offset: number;
|
|
18
|
+
} {
|
|
19
|
+
const page = Math.max(1, params.page || DEFAULT_PAGE);
|
|
20
|
+
const limit = Math.min(Math.max(1, params.limit || DEFAULT_LIMIT), MAX_LIMIT);
|
|
21
|
+
const offset = (page - 1) * limit;
|
|
22
|
+
|
|
23
|
+
return { page, limit, offset };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build pagination metadata
|
|
28
|
+
*/
|
|
29
|
+
export function buildPaginationMeta(page: number, limit: number, total: number): PaginationMeta {
|
|
30
|
+
return {
|
|
31
|
+
page,
|
|
32
|
+
limit,
|
|
33
|
+
total,
|
|
34
|
+
totalPages: Math.ceil(total / limit),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Apply pagination to Supabase query
|
|
40
|
+
*/
|
|
41
|
+
export function applyPagination(query: any, params: PaginationParams): any {
|
|
42
|
+
const { limit, offset } = parsePagination(params);
|
|
43
|
+
return query.range(offset, offset + limit - 1);
|
|
44
|
+
}
|