eletrotupi / tcc/ frontend/lib/api/client.ts8873ae72bb5c5381f95356155b0c0cb1d4041156
14.1 KB | 531 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
  VerifyTokenResponse,
19
  SignUpPayload,
20
  ResetPasswordRequestResponse,
21
  ResetPasswordResponse,
22
  UserUpdatePayload,
23
  CreateMoodEntryPayload,
24
  SleepRecordPayload,
25
  UpdateSleepRecordPayload,
26
  MoodEntry,
27
  SleepRecord,
28
  SleepRecordResponse,
29
  MoodEntryResponse,
30
  Trigger,
31
  TriggerResponse,
32
  CreateTriggerPayload,
33
  UpdateTriggerPayload,
34
  ActivateResponse,
35
  PaginatedResponse,
36
  InsightType,
37
  InsightPeriod,
38
  InsightMetadata,
39
  Insight,
40
  MedicineRegimen,
41
  MedicineRegimenResponse,
42
  CreateMedicineRegimenPayload,
43
  UpdateMedicineRegimenPayload,
44
  TodayMedicineEntry,
45
  UserPreferencesPayload,
46
  CareAction,
47
  CareActionType,
48
  CareActionResponse,
49
  CreateCareActionPayload,
50
  PatchCareActionPayload,
51
  TriggerMoodLink,
52
  LinkMoodPayload,
53
  AvatarResponse,
54
  RemoveAvatarResponse,
55
  ExportDataResponse,
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 verifyToken(): Promise<VerifyTokenResponse> {
208
    try {
209
      return await this.request("/auth/verify");
210
    } catch (error) {
211
      // Token is invalid or expired
212
      await this.removeToken();
213
      throw error;
214
    }
215
  }
216

217
  async logout(): Promise<void> {
218
    await this.removeToken();
219
  }
220

221
  // User-related
222
  async getMe(): Promise<{ user: User }> {
223
    return this.request('/users/me');
224
  }
225

226
  async updateUser(user: UserUpdatePayload): Promise<{ user: User }> {
227
    return await this.request(`/users/${user.id}`, {
228
      method: "PUT",
229
      body: JSON.stringify({ user }),
230
    });
231
  }
232

233
  async avatar(formData: FormData): Promise<AvatarResponse> {
234
    return await this.request<AvatarResponse>(`/users/me/avatar`, {
235
      method: "POST",
236
      body: formData,
237
    });
238
  }
239

240
  async removeAvatar(): Promise<RemoveAvatarResponse> {
241
    return await this.request<RemoveAvatarResponse>(`/users/me/avatar`, {
242
      method: "DELETE",
243
    });
244
  }
245

246
  // Mood-related
247
  async createMoodEntry(
248
    mood: CreateMoodEntryPayload,
249
  ): Promise<MoodEntryResponse> {
250
    return await this.request(`/moods`, {
251
      method: "POST",
252
      body: JSON.stringify({ mood }),
253
    });
254
  }
255

256
  async getMoodEntries(params?: {
257
    from?: string;
258
    to?: string;
259
    page?: number;
260
    limit?: number;
261
  }): Promise<PaginatedResponse<MoodEntry>> {
262
    const query = new URLSearchParams();
263
    if (params?.from) query.set("from", params.from);
264
    if (params?.to) query.set("to", params.to);
265
    if (params?.page) query.set("page", String(params.page));
266
    if (params?.limit) query.set("limit", String(params.limit));
267

268
    const qs = query.toString();
269
    return this.request(`/moods${qs ? `?${qs}` : ""}`);
270
  }
271

272
  async getMoodEntry(id: string): Promise<MoodEntry> {
273
    return this.request(`/moods/${id}`);
274
  }
275

276
  async deleteMoodEntry(id: string): Promise<void> {
277
    return this.request(`/moods/${id}`, { method: "DELETE" });
278
  }
279

280
  // Sleep Record
281
  async createSleepRecord(
282
    sleepRecord: SleepRecordPayload,
283
  ): Promise<SleepRecordResponse> {
284
    return await this.request(`/sleep_records`, {
285
      method: "POST",
286
      body: JSON.stringify({ sleepRecord }),
287
    });
288
  }
289

290
  async getSleepRecords(params?: {
291
    from?: string;
292
    to?: string;
293
    page?: number;
294
    limit?: number;
295
  }): Promise<PaginatedResponse<SleepRecord>> {
296
    const query = new URLSearchParams();
297
    if (params?.from) query.set("from", params.from);
298
    if (params?.to) query.set("to", params.to);
299
    if (params?.page) query.set("page", String(params.page));
300
    if (params?.limit) query.set("limit", String(params.limit));
301

302
    const qs = query.toString();
303
    return this.request(`/sleep_records${qs ? `?${qs}` : ""}`);
304
  }
305

306
  async getSleepRecord(id: string): Promise<SleepRecord> {
307
    return this.request(`/sleep_records/${id}`);
308
  }
309

310
  async updateSleepRecord(
311
    id: string,
312
    sleepRecord: UpdateSleepRecordPayload,
313
  ): Promise<SleepRecordResponse> {
314
    return await this.request(`/sleep_records/${id}`, {
315
      method: "PUT",
316
      body: JSON.stringify({ sleepRecord }),
317
    });
318
  }
319

320
  async deleteSleepRecord(id: string): Promise<void> {
321
    return this.request(`/sleep_records/${id}`, { method: "DELETE" });
322
  }
323

324
  // Trigger
325
  async createTrigger(trigger: CreateTriggerPayload): Promise<TriggerResponse> {
326
    return await this.request(`/triggers`, {
327
      method: "POST",
328
      body: JSON.stringify({ trigger }),
329
    });
330
  }
331

332
  async getTriggers(params?: {
333
    from?: string;
334
    to?: string;
335
    page?: number;
336
    limit?: number;
337
  }): Promise<PaginatedResponse<Trigger>> {
338
    const query = new URLSearchParams();
339
    if (params?.from) query.set("from", params.from);
340
    if (params?.to) query.set("to", params.to);
341
    if (params?.page) query.set("page", String(params.page));
342
    if (params?.limit) query.set("limit", String(params.limit));
343

344
    const qs = query.toString();
345
    return this.request(`/triggers${qs ? `?${qs}` : ""}`);
346
  }
347

348
  async getTrigger(id: string): Promise<Trigger> {
349
    return this.request(`/triggers/${id}`);
350
  }
351

352
  async updateTrigger(
353
    id: string,
354
    trigger: UpdateTriggerPayload,
355
  ): Promise<TriggerResponse> {
356
    return await this.request(`/triggers/${id}`, {
357
      method: "PUT",
358
      body: JSON.stringify({ trigger }),
359
    });
360
  }
361

362
  async deleteTrigger(id: string): Promise<void> {
363
    return this.request(`/triggers/${id}`, { method: "DELETE" });
364
  }
365

366
  // Insights
367
  async getInsights<T extends InsightType = InsightType>(filters?: {
368
    type?: T;
369
    period?: InsightPeriod;
370
    limit?: number;
371
  }): Promise<{ insights: Insight<T>[] }> {
372
    const query = new URLSearchParams();
373
    if (filters?.type) query.set("type", filters.type);
374
    if (filters?.period) query.set("period", filters.period);
375
    if (filters?.limit) query.set("limit", String(filters.limit));
376

377
    const qs = query.toString();
378
    return this.request<{ insights: Insight<T>[] }>(
379
      `/insights${qs ? `?${qs}` : ""}`,
380
    );
381
  }
382

383
  // Medicine Regimens
384
  async getMedicineRegimens(): Promise<{ regimens: MedicineRegimen[] }> {
385
    return this.request('/medicine-regimens');
386
  }
387

388
  async getMedicineRegimen(id: string): Promise<{ regimen: MedicineRegimen }> {
389
    return this.request(`/medicine-regimens/${id}`);
390
  }
391

392
  async createMedicineRegimen(
393
    regimen: CreateMedicineRegimenPayload
394
  ): Promise<MedicineRegimenResponse> {
395
    return this.request('/medicine-regimens', {
396
      method: 'POST',
397
      body: JSON.stringify({ regimen })
398
    });
399
  }
400

401
  async updateMedicineRegimen(
402
    id: string,
403
    regimen: UpdateMedicineRegimenPayload
404
  ): Promise<MedicineRegimenResponse> {
405
    return this.request(`/medicine-regimens/${id}`, {
406
      method: 'PATCH',
407
      body: JSON.stringify({ regimen })
408
    });
409
  }
410

411
  async deleteMedicineRegimen(id: string): Promise<void> {
412
    return this.request(`/medicine-regimens/${id}`, { method: 'DELETE' });
413
  }
414

415
  async toggleMedicineRegimen(
416
    id: string,
417
    active: boolean,
418
  ): Promise<MedicineRegimenResponse> {
419
    return this.request(`/medicine-regimens/${id}/active`, {
420
      method: 'PATCH',
421
      body: JSON.stringify({ active }),
422
    });
423
  }
424

425
  async getMedicineRegimensToday(): Promise<{ regimens: TodayMedicineEntry[] }> {
426
    return this.request('/medicine-regimens/today');
427
  }
428

429
  async patchUserMe(payload: UserPreferencesPayload): Promise<{ user: User }> {
430
    return this.request('/users/me', {
431
      method: 'PATCH',
432
      body: JSON.stringify({ user: payload }),
433
    });
434
  }
435

436
  async exportData(): Promise<ExportDataResponse> {
437
    return this.request('/exports', { method: 'POST' });
438
  }
439

440
  // Care Actions
441
  async getCareActions(
442
    params?: {
443
      type?: CareActionType;
444
      from?: string;
445
      to?: string;
446
      page?: number;
447
      limit?: number
448
    }): Promise<PaginatedResponse<CareAction>> {
449
    const query = new URLSearchParams();
450
    if (params?.type) query.set('type', params.type);
451
    if (params?.from) query.set('from', params.from);
452
    if (params?.to) query.set('to', params.to);
453
    if (params?.page) query.set('page', String(params.page));
454
    if (params?.limit) query.set('limit', String(params.limit));
455

456
    const qs = query.toString();
457

458
    return this.request(`/care-actions${qs ? `?${qs}` : ''}`);
459
  }
460

461
  async getCareAction(id: string): Promise<CareAction> {
462
    return this.request(`/care-actions/${id}`);
463
  }
464

465
  async createCareAction(payload: CreateCareActionPayload): Promise<CareActionResponse> {
466
    return this.request('/care-actions', {
467
      method: 'POST', body: JSON.stringify({ careAction: payload })
468
    });
469
  }
470

471
  async patchCareAction(
472
    id: string,
473
    payload: PatchCareActionPayload,
474
  ): Promise<CareAction> {
475
    return this.request(`/care-actions/${id}`, {
476
      method: 'PATCH',
477
      body: JSON.stringify({ careAction: payload }),
478
    });
479
  }
480

481
  async deleteCareAction(id: string): Promise<void> {
482
    return this.request(`/care-actions/${id}`, { method: 'DELETE' });
483
  }
484

485
  // Trigger to Mood linking
486
  async linkMoodToTrigger(
487
    triggerId: string, payload: LinkMoodPayload
488
  ): Promise<{ link: TriggerMoodLink }> {
489
    return this.request(`/triggers/${triggerId}/link-mood`, {
490
      method: 'POST',
491
      body: JSON.stringify(payload)
492
    });
493
  }
494

495
  async unlinkMoodFromTrigger(triggerId: string, moodId: string): Promise<void> {
496
    return this.request(`/triggers/${triggerId}/link-mood/${moodId}`, { method: 'DELETE' });
497
  }
498

499
  async generateReport(
500
    periodStart: string,
501
    periodEnd: string,
502
  ): Promise<ArrayBuffer> {
503
    const token = await this.getStoredToken()
504

505
    let response: Response
506
    try {
507
      response = await fetch(`${API_BASE_URL}/reports`, {
508
        method: 'POST',
509
        headers: {
510
          'Content-Type': 'application/json',
511
          ...(token && { Authorization: `Bearer ${token}` }),
512
        },
513
        body: JSON.stringify({ periodStart, periodEnd }),
514
      })
515
    } catch {
516
      throw new ApiError('Sem conexão com o servidor', 0)
517
    }
518

519
    if (!response.ok) {
520
      if (response.status === 401) this.onUnauthorizedCallback?.()
521
      const body = await response.json().catch(() => ({
522
        error: `HTTP ${response.status}`,
523
      }))
524
      throw new ApiError(body.error ?? `HTTP ${response.status}`, response.status)
525
    }
526

527
    return response.arrayBuffer()
528
  }
529
}
530

531
export const apiClient = new ApiClient();