eletrotupi / tcc/ frontend/lib/api/client.ts11443325051f8ebf8d5bdcba4d184e4c95d1f887
14.0 KB | 524 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
} from "@/lib/api/types";
56

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

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

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

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

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

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

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

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

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

120
    let response: Response;
121

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

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

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

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

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

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

149
    return response.json();
150
  }
151

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

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

163
    return response;
164
  }
165

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

433
  // Care Actions
434
  async getCareActions(
435
    params?: {
436
      type?: CareActionType;
437
      from?: string;
438
      to?: string;
439
      page?: number;
440
      limit?: number
441
    }): Promise<PaginatedResponse<CareAction>> {
442
    const query = new URLSearchParams();
443
    if (params?.type) query.set('type', params.type);
444
    if (params?.from) query.set('from', params.from);
445
    if (params?.to) query.set('to', params.to);
446
    if (params?.page) query.set('page', String(params.page));
447
    if (params?.limit) query.set('limit', String(params.limit));
448

449
    const qs = query.toString();
450

451
    return this.request(`/care-actions${qs ? `?${qs}` : ''}`);
452
  }
453

454
  async getCareAction(id: string): Promise<CareAction> {
455
    return this.request(`/care-actions/${id}`);
456
  }
457

458
  async createCareAction(payload: CreateCareActionPayload): Promise<CareActionResponse> {
459
    return this.request('/care-actions', {
460
      method: 'POST', body: JSON.stringify({ careAction: payload })
461
    });
462
  }
463

464
  async patchCareAction(
465
    id: string,
466
    payload: PatchCareActionPayload,
467
  ): Promise<CareAction> {
468
    return this.request(`/care-actions/${id}`, {
469
      method: 'PATCH',
470
      body: JSON.stringify({ careAction: payload }),
471
    });
472
  }
473

474
  async deleteCareAction(id: string): Promise<void> {
475
    return this.request(`/care-actions/${id}`, { method: 'DELETE' });
476
  }
477

478
  // Trigger to Mood linking
479
  async linkMoodToTrigger(
480
    triggerId: string, payload: LinkMoodPayload
481
  ): Promise<{ link: TriggerMoodLink }> {
482
    return this.request(`/triggers/${triggerId}/link-mood`, {
483
      method: 'POST',
484
      body: JSON.stringify(payload)
485
    });
486
  }
487

488
  async unlinkMoodFromTrigger(triggerId: string, moodId: string): Promise<void> {
489
    return this.request(`/triggers/${triggerId}/link-mood/${moodId}`, { method: 'DELETE' });
490
  }
491

492
  async generateReport(
493
    periodStart: string,
494
    periodEnd: string,
495
  ): Promise<ArrayBuffer> {
496
    const token = await this.getStoredToken()
497

498
    let response: Response
499
    try {
500
      response = await fetch(`${API_BASE_URL}/reports`, {
501
        method: 'POST',
502
        headers: {
503
          'Content-Type': 'application/json',
504
          ...(token && { Authorization: `Bearer ${token}` }),
505
        },
506
        body: JSON.stringify({ periodStart, periodEnd }),
507
      })
508
    } catch {
509
      throw new ApiError('Sem conexão com o servidor', 0)
510
    }
511

512
    if (!response.ok) {
513
      if (response.status === 401) this.onUnauthorizedCallback?.()
514
      const body = await response.json().catch(() => ({
515
        error: `HTTP ${response.status}`,
516
      }))
517
      throw new ApiError(body.error ?? `HTTP ${response.status}`, response.status)
518
    }
519

520
    return response.arrayBuffer()
521
  }
522
}
523

524
export const apiClient = new ApiClient();