eletrotupi / tcc/ frontend/lib/api/client.tsf8f41f3d02a26b63d278ed849f86e913d1ea381b
14.4 KB | 538 lines Raw
1
import * as SecureStore from "expo-secure-store";
2
import Constants from "expo-constants";
3

4
export class ApiError extends Error {
5
  status: number;
6
  fields?: Record<string, string>;
7

8
  constructor(message: string, status: number, fields?: Record<string, string>) {
9
    super(message);
10
    this.name = 'ApiError';
11
    this.status = status;
12
    this.fields = fields;
13
  }
14
}
15
import {
16
  User,
17
  AuthResponse,
18
  SignUpPayload,
19
  ResetPasswordRequestResponse,
20
  ResetPasswordResponse,
21
  UserUpdatePayload,
22
  CreateMoodEntryPayload,
23
  SleepRecordPayload,
24
  UpdateSleepRecordPayload,
25
  MoodEntry,
26
  SleepRecord,
27
  SleepRecordResponse,
28
  MoodEntryResponse,
29
  Trigger,
30
  TriggerResponse,
31
  CreateTriggerPayload,
32
  UpdateTriggerPayload,
33
  ActivateResponse,
34
  PaginatedResponse,
35
  InsightType,
36
  InsightPeriod,
37
  InsightMetadata,
38
  Insight,
39
  MedicineRegimen,
40
  MedicineRegimenResponse,
41
  CreateMedicineRegimenPayload,
42
  UpdateMedicineRegimenPayload,
43
  TodayMedicineEntry,
44
  UserPreferencesPayload,
45
  CareAction,
46
  CareActionType,
47
  CareActionResponse,
48
  CreateCareActionPayload,
49
  PatchCareActionPayload,
50
  TriggerMoodLink,
51
  LinkMoodPayload,
52
  AvatarResponse,
53
  RemoveAvatarResponse,
54
  ExportDataResponse,
55
  NotificationsPage,
56
} from "@/lib/api/types";
57

58
// Development builds already talk to Metro, so the API is on the same host.
59
// Release builds have no hostUri and always take the env var.
60
const API_BASE_URL =
61
  process.env.EXPO_PUBLIC_API_BASE_URL ??
62
  (Constants.expoConfig?.hostUri
63
    ? `http://${Constants.expoConfig.hostUri.split(":")[0]}:3000`
64
    : undefined);
65
const TOKEN_KEY = process.env.EXPO_PUBLIC_TOKEN_KEY;
66

67
class ApiClient {
68
  private onUnauthorizedCallback?: () => void;
69

70
  registerUnauthorizedHandler(cb: () => void) {
71
    this.onUnauthorizedCallback = cb;
72
  }
73

74
  private async getStoredToken(): Promise<string | null> {
75
    try {
76
      return await SecureStore.getItemAsync(TOKEN_KEY!);
77
    } catch (error) {
78
      console.error("Failed to get stored token:", error);
79
      return null;
80
    }
81
  }
82

83
  private async storeToken(token: string): Promise<void> {
84
    try {
85
      await SecureStore.setItemAsync(TOKEN_KEY!, token);
86
    } catch (error) {
87
      console.error("Failed to store token:", error);
88
      throw error;
89
    }
90
  }
91

92
  private async removeToken(): Promise<void> {
93
    try {
94
      await SecureStore.deleteItemAsync(TOKEN_KEY!);
95
    } catch (error) {
96
      console.error("Failed to remove token:", error);
97
    }
98
  }
99

100
  async getStoredAuthData(): Promise<{ token: string | null }> {
101
    const token = await this.getStoredToken();
102
    return { token };
103
  }
104

105
  private async request<T>(
106
    endpoint: string,
107
    options: RequestInit = {},
108
  ): Promise<T> {
109
    const token = await this.getStoredToken();
110
    const isFormData = options.body instanceof FormData;
111

112
    const config: RequestInit = {
113
      ...options,
114
      headers: {
115
        ...(token && { Authorization: `Bearer ${token}` }),
116
        ...(!isFormData && { "Content-Type": "application/json" }),
117
        ...options.headers,
118
      },
119
    };
120

121
    let response: Response;
122

123
    console.log(`${API_BASE_URL}${endpoint} => with ${JSON.stringify(config)}`)
124

125
    try {
126
      response = await fetch(`${API_BASE_URL}${endpoint}`, config);
127
    } catch (err) {
128
      console.error("Underlying error: ", err)
129
      throw new ApiError("Sem conexão com o servidor", 0);
130
    }
131

132
    if (!response.ok) {
133
      if (response.status === 401) {
134
        this.onUnauthorizedCallback?.();
135
      }
136

137
      const body = await response
138
        .json()
139
        .catch(() => ({ error: "Network error" }));
140

141
      console.error("[API]: ", body, response.status);
142

143
      throw new ApiError(
144
        body.error || `HTTP ${response.status}`,
145
        response.status,
146
        body.fields,
147
      );
148
    }
149

150
    return response.json();
151
  }
152

153
  // Auth
154
  async login(email: string, password: string): Promise<AuthResponse> {
155
    const response = await this.request<AuthResponse>("/auth/login", {
156
      method: "POST",
157
      body: JSON.stringify({ email, password }),
158
    });
159

160
    if (response.token) {
161
      await this.storeToken(response.token);
162
    }
163

164
    return response;
165
  }
166

167
  async activate(code: string): Promise<ActivateResponse> {
168
    return this.request("/auth/activate", {
169
      method: "POST",
170
      body: JSON.stringify({ code }),
171
    });
172
  }
173

174
  async requestActivateCode(): Promise<void> {
175
    return this.request("/auth/resend_code", {
176
      method: "POST",
177
    });
178
  }
179

180
  async signup(user: SignUpPayload): Promise<AuthResponse> {
181
    const response = await this.request<AuthResponse>("/users", {
182
      method: "POST",
183
      body: JSON.stringify({ user }),
184
    });
185

186
    await this.storeToken(response.token);
187
    return response;
188
  }
189

190
  async forgotPassword(email: string): Promise<ResetPasswordRequestResponse> {
191
    return this.request("/auth/forgot-password", {
192
      method: "POST",
193
      body: JSON.stringify({ email }),
194
    });
195
  }
196

197
  async resetPassword(
198
    token: string,
199
    password: string,
200
  ): Promise<ResetPasswordResponse> {
201
    return this.request("/auth/reset-password", {
202
      method: "POST",
203
      body: JSON.stringify({ token, newPassword: password }),
204
    });
205
  }
206

207
  async logout(): Promise<void> {
208
    await this.removeToken();
209
  }
210

211
  // User-related
212
  async getMe(): Promise<{ user: User }> {
213
    return this.request('/users/me');
214
  }
215

216
  async completeOnboarding(): Promise<{ user: User }> {
217
    return this.request('/users/me/onboarding/complete', { method: 'POST' });
218
  }
219

220
  async updateUser(user: UserUpdatePayload): Promise<{ user: User }> {
221
    return await this.request(`/users/${user.id}`, {
222
      method: "PUT",
223
      body: JSON.stringify({ user }),
224
    });
225
  }
226

227
  async avatar(formData: FormData): Promise<AvatarResponse> {
228
    return await this.request<AvatarResponse>(`/users/me/avatar`, {
229
      method: "POST",
230
      body: formData,
231
    });
232
  }
233

234
  async removeAvatar(): Promise<RemoveAvatarResponse> {
235
    return await this.request<RemoveAvatarResponse>(`/users/me/avatar`, {
236
      method: "DELETE",
237
    });
238
  }
239

240
  // Mood-related
241
  async createMoodEntry(
242
    mood: CreateMoodEntryPayload,
243
  ): Promise<MoodEntryResponse> {
244
    return await this.request(`/moods`, {
245
      method: "POST",
246
      body: JSON.stringify({ mood }),
247
    });
248
  }
249

250
  async getMoodEntries(params?: {
251
    from?: string;
252
    to?: string;
253
    page?: number;
254
    limit?: number;
255
  }): Promise<PaginatedResponse<MoodEntry>> {
256
    const query = new URLSearchParams();
257
    if (params?.from) query.set("from", params.from);
258
    if (params?.to) query.set("to", params.to);
259
    if (params?.page) query.set("page", String(params.page));
260
    if (params?.limit) query.set("limit", String(params.limit));
261

262
    const qs = query.toString();
263
    return this.request(`/moods${qs ? `?${qs}` : ""}`);
264
  }
265

266
  async getMoodEntry(id: string): Promise<MoodEntry> {
267
    return this.request(`/moods/${id}`);
268
  }
269

270
  async deleteMoodEntry(id: string): Promise<void> {
271
    return this.request(`/moods/${id}`, { method: "DELETE" });
272
  }
273

274
  // Sleep Record
275
  async createSleepRecord(
276
    sleepRecord: SleepRecordPayload,
277
  ): Promise<SleepRecordResponse> {
278
    return await this.request(`/sleep_records`, {
279
      method: "POST",
280
      body: JSON.stringify({ sleepRecord }),
281
    });
282
  }
283

284
  async getSleepRecords(params?: {
285
    from?: string;
286
    to?: string;
287
    page?: number;
288
    limit?: number;
289
  }): Promise<PaginatedResponse<SleepRecord>> {
290
    const query = new URLSearchParams();
291
    if (params?.from) query.set("from", params.from);
292
    if (params?.to) query.set("to", params.to);
293
    if (params?.page) query.set("page", String(params.page));
294
    if (params?.limit) query.set("limit", String(params.limit));
295

296
    const qs = query.toString();
297
    return this.request(`/sleep_records${qs ? `?${qs}` : ""}`);
298
  }
299

300
  async getSleepRecord(id: string): Promise<SleepRecord> {
301
    return this.request(`/sleep_records/${id}`);
302
  }
303

304
  async updateSleepRecord(
305
    id: string,
306
    sleepRecord: UpdateSleepRecordPayload,
307
  ): Promise<SleepRecordResponse> {
308
    return await this.request(`/sleep_records/${id}`, {
309
      method: "PUT",
310
      body: JSON.stringify({ sleepRecord }),
311
    });
312
  }
313

314
  async deleteSleepRecord(id: string): Promise<void> {
315
    return this.request(`/sleep_records/${id}`, { method: "DELETE" });
316
  }
317

318
  // Trigger
319
  async createTrigger(trigger: CreateTriggerPayload): Promise<TriggerResponse> {
320
    return await this.request(`/triggers`, {
321
      method: "POST",
322
      body: JSON.stringify({ trigger }),
323
    });
324
  }
325

326
  async getTriggers(params?: {
327
    from?: string;
328
    to?: string;
329
    page?: number;
330
    limit?: number;
331
  }): Promise<PaginatedResponse<Trigger>> {
332
    const query = new URLSearchParams();
333
    if (params?.from) query.set("from", params.from);
334
    if (params?.to) query.set("to", params.to);
335
    if (params?.page) query.set("page", String(params.page));
336
    if (params?.limit) query.set("limit", String(params.limit));
337

338
    const qs = query.toString();
339
    return this.request(`/triggers${qs ? `?${qs}` : ""}`);
340
  }
341

342
  async getTrigger(id: string): Promise<Trigger> {
343
    return this.request(`/triggers/${id}`);
344
  }
345

346
  async updateTrigger(
347
    id: string,
348
    trigger: UpdateTriggerPayload,
349
  ): Promise<TriggerResponse> {
350
    return await this.request(`/triggers/${id}`, {
351
      method: "PUT",
352
      body: JSON.stringify({ trigger }),
353
    });
354
  }
355

356
  async deleteTrigger(id: string): Promise<void> {
357
    return this.request(`/triggers/${id}`, { method: "DELETE" });
358
  }
359

360
  // Insights
361
  async getInsights<T extends InsightType = InsightType>(filters?: {
362
    type?: T;
363
    period?: InsightPeriod;
364
    limit?: number;
365
  }): Promise<{ insights: Insight<T>[] }> {
366
    const query = new URLSearchParams();
367
    if (filters?.type) query.set("type", filters.type);
368
    if (filters?.period) query.set("period", filters.period);
369
    if (filters?.limit) query.set("limit", String(filters.limit));
370

371
    const qs = query.toString();
372
    return this.request<{ insights: Insight<T>[] }>(
373
      `/insights${qs ? `?${qs}` : ""}`,
374
    );
375
  }
376

377
  // Medicine Regimens
378
  async getMedicineRegimens(): Promise<{ regimens: MedicineRegimen[] }> {
379
    return this.request('/medicine-regimens');
380
  }
381

382
  async getMedicineRegimen(id: string): Promise<{ regimen: MedicineRegimen }> {
383
    return this.request(`/medicine-regimens/${id}`);
384
  }
385

386
  async createMedicineRegimen(
387
    regimen: CreateMedicineRegimenPayload
388
  ): Promise<MedicineRegimenResponse> {
389
    return this.request('/medicine-regimens', {
390
      method: 'POST',
391
      body: JSON.stringify({ regimen })
392
    });
393
  }
394

395
  async updateMedicineRegimen(
396
    id: string,
397
    regimen: UpdateMedicineRegimenPayload
398
  ): Promise<MedicineRegimenResponse> {
399
    return this.request(`/medicine-regimens/${id}`, {
400
      method: 'PATCH',
401
      body: JSON.stringify({ regimen })
402
    });
403
  }
404

405
  async deleteMedicineRegimen(id: string): Promise<void> {
406
    return this.request(`/medicine-regimens/${id}`, { method: 'DELETE' });
407
  }
408

409
  async toggleMedicineRegimen(
410
    id: string,
411
    active: boolean,
412
  ): Promise<MedicineRegimenResponse> {
413
    return this.request(`/medicine-regimens/${id}/active`, {
414
      method: 'PATCH',
415
      body: JSON.stringify({ active }),
416
    });
417
  }
418

419
  async getMedicineRegimensToday(): Promise<{ regimens: TodayMedicineEntry[] }> {
420
    return this.request('/medicine-regimens/today');
421
  }
422

423
  async patchUserMe(payload: UserPreferencesPayload): Promise<{ user: User }> {
424
    return this.request('/users/me', {
425
      method: 'PATCH',
426
      body: JSON.stringify({ user: payload }),
427
    });
428
  }
429

430
  async exportData(): Promise<ExportDataResponse> {
431
    return this.request('/exports', { method: 'POST' });
432
  }
433

434
  // Notifications
435
  async getNotifications(params?: {
436
    limit?: number;
437
    cursor?: number;
438
  }): Promise<NotificationsPage> {
439
    const query = new URLSearchParams();
440
    if (params?.limit) query.set('limit', String(params.limit));
441
    if (params?.cursor) query.set('cursor', String(params.cursor));
442

443
    const qs = query.toString();
444
    return this.request(`/notifications${qs ? `?${qs}` : ''}`);
445
  }
446

447
  // Care Actions
448
  async getCareActions(
449
    params?: {
450
      type?: CareActionType;
451
      from?: string;
452
      to?: string;
453
      page?: number;
454
      limit?: number
455
    }): Promise<PaginatedResponse<CareAction>> {
456
    const query = new URLSearchParams();
457
    if (params?.type) query.set('type', params.type);
458
    if (params?.from) query.set('from', params.from);
459
    if (params?.to) query.set('to', params.to);
460
    if (params?.page) query.set('page', String(params.page));
461
    if (params?.limit) query.set('limit', String(params.limit));
462

463
    const qs = query.toString();
464

465
    return this.request(`/care-actions${qs ? `?${qs}` : ''}`);
466
  }
467

468
  async getCareAction(id: string): Promise<CareAction> {
469
    return this.request(`/care-actions/${id}`);
470
  }
471

472
  async createCareAction(payload: CreateCareActionPayload): Promise<CareActionResponse> {
473
    return this.request('/care-actions', {
474
      method: 'POST', body: JSON.stringify({ careAction: payload })
475
    });
476
  }
477

478
  async patchCareAction(
479
    id: string,
480
    payload: PatchCareActionPayload,
481
  ): Promise<CareAction> {
482
    return this.request(`/care-actions/${id}`, {
483
      method: 'PATCH',
484
      body: JSON.stringify({ careAction: payload }),
485
    });
486
  }
487

488
  async deleteCareAction(id: string): Promise<void> {
489
    return this.request(`/care-actions/${id}`, { method: 'DELETE' });
490
  }
491

492
  // Trigger to Mood linking
493
  async linkMoodToTrigger(
494
    triggerId: string, payload: LinkMoodPayload
495
  ): Promise<{ link: TriggerMoodLink }> {
496
    return this.request(`/triggers/${triggerId}/link-mood`, {
497
      method: 'POST',
498
      body: JSON.stringify(payload)
499
    });
500
  }
501

502
  async unlinkMoodFromTrigger(triggerId: string, moodId: string): Promise<void> {
503
    return this.request(`/triggers/${triggerId}/link-mood/${moodId}`, { method: 'DELETE' });
504
  }
505

506
  async generateReport(
507
    periodStart: string,
508
    periodEnd: string,
509
  ): Promise<ArrayBuffer> {
510
    const token = await this.getStoredToken()
511

512
    let response: Response
513
    try {
514
      response = await fetch(`${API_BASE_URL}/reports`, {
515
        method: 'POST',
516
        headers: {
517
          'Content-Type': 'application/json',
518
          ...(token && { Authorization: `Bearer ${token}` }),
519
        },
520
        body: JSON.stringify({ periodStart, periodEnd }),
521
      })
522
    } catch {
523
      throw new ApiError('Sem conexão com o servidor', 0)
524
    }
525

526
    if (!response.ok) {
527
      if (response.status === 401) this.onUnauthorizedCallback?.()
528
      const body = await response.json().catch(() => ({
529
        error: `HTTP ${response.status}`,
530
      }))
531
      throw new ApiError(body.error ?? `HTTP ${response.status}`, response.status)
532
    }
533

534
    return response.arrayBuffer()
535
  }
536
}
537

538
export const apiClient = new ApiClient();