eletrotupi / tcc/ frontend/lib/api/client.ts76378ec71e47317083b91d0e67d08a0ddc4d94cd
13.6 KB | 517 lines Raw
1
import * as SecureStore from "expo-secure-store";
2

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

7
  constructor(message: string, status: number, fields?: Record<string, string>) {
8
    super(message);
9
    this.name = 'ApiError';
10
    this.status = status;
11
    this.fields = fields;
12
  }
13
}
14
import {
15
  User,
16
  AuthResponse,
17
  VerifyTokenResponse,
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
} from "@/lib/api/types";
55

56
const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL;
57
const TOKEN_KEY = process.env.EXPO_PUBLIC_TOKEN_KEY;
58

59
class ApiClient {
60
  private onUnauthorizedCallback?: () => void;
61

62
  registerUnauthorizedHandler(cb: () => void) {
63
    this.onUnauthorizedCallback = cb;
64
  }
65

66
  private async getStoredToken(): Promise<string | null> {
67
    try {
68
      return await SecureStore.getItemAsync(TOKEN_KEY!);
69
    } catch (error) {
70
      console.error("Failed to get stored token:", error);
71
      return null;
72
    }
73
  }
74

75
  private async storeToken(token: string): Promise<void> {
76
    try {
77
      await SecureStore.setItemAsync(TOKEN_KEY!, token);
78
    } catch (error) {
79
      console.error("Failed to store token:", error);
80
      throw error;
81
    }
82
  }
83

84
  private async removeToken(): Promise<void> {
85
    try {
86
      await SecureStore.deleteItemAsync(TOKEN_KEY!);
87
    } catch (error) {
88
      console.error("Failed to remove token:", error);
89
    }
90
  }
91

92
  async getStoredAuthData(): Promise<{ token: string | null }> {
93
    const token = await this.getStoredToken();
94
    return { token };
95
  }
96

97
  private async request<T>(
98
    endpoint: string,
99
    options: RequestInit = {},
100
  ): Promise<T> {
101
    const token = await this.getStoredToken();
102
    const isFormData = options.body instanceof FormData;
103

104
    const config: RequestInit = {
105
      ...options,
106
      headers: {
107
        ...(token && { Authorization: `Bearer ${token}` }),
108
        ...(!isFormData && { "Content-Type": "application/json" }),
109
        ...options.headers,
110
      },
111
    };
112

113
    let response: Response;
114

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

117
    try {
118
      response = await fetch(`${API_BASE_URL}${endpoint}`, config);
119
    } catch (err) {
120
      console.error("Underlying error: ", err)
121
      throw new ApiError("Sem conexão com o servidor", 0);
122
    }
123

124
    if (!response.ok) {
125
      if (response.status === 401) {
126
        this.onUnauthorizedCallback?.();
127
      }
128

129
      const body = await response
130
        .json()
131
        .catch(() => ({ error: "Network error" }));
132

133
      console.error("[API]: ", body, response.status);
134

135
      throw new ApiError(
136
        body.error || `HTTP ${response.status}`,
137
        response.status,
138
        body.fields,
139
      );
140
    }
141

142
    return response.json();
143
  }
144

145
  // Auth
146
  async login(email: string, password: string): Promise<AuthResponse> {
147
    const response = await this.request<AuthResponse>("/auth/login", {
148
      method: "POST",
149
      body: JSON.stringify({ email, password }),
150
    });
151

152
    if (response.token) {
153
      await this.storeToken(response.token);
154
    }
155

156
    return response;
157
  }
158

159
  async activate(code: number): Promise<ActivateResponse> {
160
    return this.request("/auth/activate", {
161
      method: "POST",
162
      body: JSON.stringify({ code }),
163
    });
164
  }
165

166
  async requestActivateCode(): Promise<void> {
167
    return this.request("/auth/resend_code", {
168
      method: "POST",
169
    });
170
  }
171

172
  async signup(user: SignUpPayload): Promise<AuthResponse> {
173
    const response = await this.request<AuthResponse>("/users", {
174
      method: "POST",
175
      body: JSON.stringify({ user }),
176
    });
177

178
    await this.storeToken(response.token);
179
    return response;
180
  }
181

182
  async forgotPassword(email: string): Promise<ResetPasswordRequestResponse> {
183
    return this.request("/auth/forgot-password", {
184
      method: "POST",
185
      body: JSON.stringify({ email }),
186
    });
187
  }
188

189
  async resetPassword(
190
    token: string,
191
    password: string,
192
  ): Promise<ResetPasswordResponse> {
193
    return this.request("/auth/reset-password", {
194
      method: "POST",
195
      body: JSON.stringify({ token, newPassword: password }),
196
    });
197
  }
198

199
  async verifyToken(): Promise<VerifyTokenResponse> {
200
    try {
201
      return await this.request("/auth/verify");
202
    } catch (error) {
203
      // Token is invalid or expired
204
      await this.removeToken();
205
      throw error;
206
    }
207
  }
208

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

369
    const qs = query.toString();
370
    return this.request(`/insights${qs ? `?${qs}` : ""}`);
371
  }
372

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

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

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

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

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

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

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

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

426
  // Care Actions
427
  async getCareActions(
428
    params?: {
429
      type?: CareActionType;
430
      from?: string;
431
      to?: string;
432
      page?: number;
433
      limit?: number
434
    }): Promise<PaginatedResponse<CareAction>> {
435
    const query = new URLSearchParams();
436
    if (params?.type) query.set('type', params.type);
437
    if (params?.from) query.set('from', params.from);
438
    if (params?.to) query.set('to', params.to);
439
    if (params?.page) query.set('page', String(params.page));
440
    if (params?.limit) query.set('limit', String(params.limit));
441

442
    const qs = query.toString();
443

444
    return this.request(`/care-actions${qs ? `?${qs}` : ''}`);
445
  }
446

447
  async getCareAction(id: string): Promise<CareAction> {
448
    return this.request(`/care-actions/${id}`);
449
  }
450

451
  async createCareAction(payload: CreateCareActionPayload): Promise<CareActionResponse> {
452
    return this.request('/care-actions', {
453
      method: 'POST', body: JSON.stringify({ careAction: payload })
454
    });
455
  }
456

457
  async patchCareAction(
458
    id: string,
459
    payload: PatchCareActionPayload,
460
  ): Promise<CareAction> {
461
    return this.request(`/care-actions/${id}`, {
462
      method: 'PATCH',
463
      body: JSON.stringify({ careAction: payload }),
464
    });
465
  }
466

467
  async deleteCareAction(id: string): Promise<void> {
468
    return this.request(`/care-actions/${id}`, { method: 'DELETE' });
469
  }
470

471
  // Trigger to Mood linking
472
  async linkMoodToTrigger(
473
    triggerId: string, payload: LinkMoodPayload
474
  ): Promise<{ link: TriggerMoodLink }> {
475
    return this.request(`/triggers/${triggerId}/link-mood`, {
476
      method: 'POST',
477
      body: JSON.stringify(payload)
478
    });
479
  }
480

481
  async unlinkMoodFromTrigger(triggerId: string, moodId: string): Promise<void> {
482
    return this.request(`/triggers/${triggerId}/link-mood/${moodId}`, { method: 'DELETE' });
483
  }
484

485
  async generateReport(
486
    periodStart: string,
487
    periodEnd: string,
488
  ): Promise<ArrayBuffer> {
489
    const token = await this.getStoredToken()
490

491
    let response: Response
492
    try {
493
      response = await fetch(`${API_BASE_URL}/reports`, {
494
        method: 'POST',
495
        headers: {
496
          'Content-Type': 'application/json',
497
          ...(token && { Authorization: `Bearer ${token}` }),
498
        },
499
        body: JSON.stringify({ periodStart, periodEnd }),
500
      })
501
    } catch {
502
      throw new ApiError('Sem conexão com o servidor', 0)
503
    }
504

505
    if (!response.ok) {
506
      if (response.status === 401) this.onUnauthorizedCallback?.()
507
      const body = await response.json().catch(() => ({
508
        error: `HTTP ${response.status}`,
509
      }))
510
      throw new ApiError(body.error ?? `HTTP ${response.status}`, response.status)
511
    }
512

513
    return response.arrayBuffer()
514
  }
515
}
516

517
export const apiClient = new ApiClient();