eletrotupi / tcc/ frontend/lib/api/client.ts37df513ec523141502d830ba76573cee4dcf928a
14.0 KB | 529 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(filters?: {
368
    type?: InsightType;
369
    period?: InsightPeriod;
370
    limit?: number;
371
  }): Promise<{ insights: Insight[] }> {
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${qs ? `?${qs}` : ""}`);
379
  }
380

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

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

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

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

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

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

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

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

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

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

454
    const qs = query.toString();
455

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

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

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

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

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

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

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

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

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

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

525
    return response.arrayBuffer()
526
  }
527
}
528

529
export const apiClient = new ApiClient();