eletrotupi / tcc/ frontend/lib/api/client.tsee68bbccb28c68fc7c4fb62c41355e4bd2e50eaf
13.7 KB | 522 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
  ExportDataResponse,
55
} from "@/lib/api/types";
56

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

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

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

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

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

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

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

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

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

114
    let response: Response;
115

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

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

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

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

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

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

143
    return response.json();
144
  }
145

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

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

157
    return response;
158
  }
159

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

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

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

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

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

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

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

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

214
  // User-related
215
  async getMe(): Promise<{ user: User }> {
216
    return this.request('/users/me');
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(filters?: {
361
    type?: InsightType;
362
    period?: InsightPeriod;
363
    limit?: number;
364
  }): Promise<{ insights: Insight[] }> {
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${qs ? `?${qs}` : ""}`);
372
  }
373

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

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

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

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

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

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

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

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

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

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

447
    const qs = query.toString();
448

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

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

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

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

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

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

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

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

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

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

518
    return response.arrayBuffer()
519
  }
520
}
521

522
export const apiClient = new ApiClient();