eletrotupi / tcc/ frontend/lib/api/client.ts485f02189668c0fdb711c8593614af2e32d6da49
13.4 KB | 514 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
} from "@/lib/api/types";
53

54
const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL;
55
const TOKEN_KEY = process.env.EXPO_PUBLIC_TOKEN_KEY;
56

57
class ApiClient {
58
  private onUnauthorizedCallback?: () => void;
59

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

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

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

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

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

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

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

111
    let response: Response;
112
    try {
113
      response = await fetch(`${API_BASE_URL}${endpoint}`, config);
114
    } catch {
115
      throw new ApiError("Sem conexão com o servidor", 0);
116
    }
117

118
    if (!response.ok) {
119
      if (response.status === 401) {
120
        this.onUnauthorizedCallback?.();
121
      }
122

123
      const body = await response
124
        .json()
125
        .catch(() => ({ error: "Network error" }));
126

127
      console.error("[API]: ", body, response.status);
128

129
      throw new ApiError(
130
        body.error || `HTTP ${response.status}`,
131
        response.status,
132
        body.fields,
133
      );
134
    }
135

136
    return response.json();
137
  }
138

139
  // Auth
140
  async login(email: string, password: string): Promise<AuthResponse> {
141
    const response = await this.request<AuthResponse>("/auth/login", {
142
      method: "POST",
143
      body: JSON.stringify({ email, password }),
144
    });
145

146
    if (response.token) {
147
      await this.storeToken(response.token);
148
    }
149

150
    return response;
151
  }
152

153
  async activate(code: number): Promise<ActivateResponse> {
154
    return this.request("/auth/activate", {
155
      method: "POST",
156
      body: JSON.stringify({ code }),
157
    });
158
  }
159

160
  async requestActivateCode(): Promise<void> {
161
    return this.request("/auth/resend_code", {
162
      method: "POST",
163
    });
164
  }
165

166
  async signup(user: SignUpPayload): Promise<AuthResponse> {
167
    const response = await this.request<AuthResponse>("/users", {
168
      method: "POST",
169
      body: JSON.stringify({ user }),
170
    });
171

172
    await this.storeToken(response.token);
173
    return response;
174
  }
175

176
  async forgotPassword(email: string): Promise<ResetPasswordRequestResponse> {
177
    return this.request("/auth/forgot-password", {
178
      method: "POST",
179
      body: JSON.stringify({ email }),
180
    });
181
  }
182

183
  async resetPassword(
184
    token: string,
185
    password: string,
186
  ): Promise<ResetPasswordResponse> {
187
    return this.request("/auth/reset-password", {
188
      method: "POST",
189
      body: JSON.stringify({ token, newPassword: password }),
190
    });
191
  }
192

193
  async verifyToken(): Promise<VerifyTokenResponse> {
194
    try {
195
      return await this.request("/auth/verify");
196
    } catch (error) {
197
      // Token is invalid or expired
198
      await this.removeToken();
199
      throw error;
200
    }
201
  }
202

203
  async logout(): Promise<void> {
204
    await this.removeToken();
205
  }
206

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

212
  async updateUser(user: UserUpdatePayload): Promise<{ user: User }> {
213
    return await this.request(`/users/${user.id}`, {
214
      method: "PUT",
215
      body: JSON.stringify({ user }),
216
    });
217
  }
218

219
  async avatar(formData: FormData): Promise<User> {
220
    return await this.request(`/users/me/avatar`, {
221
      method: "POST",
222
      body: formData,
223
      headers: {
224
        "Content-Type": "multipart/form-data",
225
      } as any,
226
    });
227
  }
228

229
  async removeAvatar(): Promise<void> {
230
    return await this.request(`/users/me/avatar`, {
231
      method: "DELETE",
232
    });
233
  }
234

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

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

257
    const qs = query.toString();
258
    return this.request(`/moods${qs ? `?${qs}` : ""}`);
259
  }
260

261
  async getMoodEntry(id: string): Promise<MoodEntry> {
262
    return this.request(`/moods/${id}`);
263
  }
264

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

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

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

291
    const qs = query.toString();
292
    return this.request(`/sleep_records${qs ? `?${qs}` : ""}`);
293
  }
294

295
  async getSleepRecord(id: string): Promise<SleepRecord> {
296
    return this.request(`/sleep_records/${id}`);
297
  }
298

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

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

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

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

333
    const qs = query.toString();
334
    return this.request(`/triggers${qs ? `?${qs}` : ""}`);
335
  }
336

337
  async getTrigger(id: string): Promise<Trigger> {
338
    return this.request(`/triggers/${id}`);
339
  }
340

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

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

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

366
    const qs = query.toString();
367
    return this.request(`/insights${qs ? `?${qs}` : ""}`);
368
  }
369

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

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

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

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

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

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

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

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

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

439
    const qs = query.toString();
440

441
    return this.request(`/care-actions${qs ? `?${qs}` : ''}`);
442
  }
443

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

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

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

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

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

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

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

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

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

510
    return response.arrayBuffer()
511
  }
512
}
513

514
export const apiClient = new ApiClient();